DFiP_Budget_planing/docs/superpowers/plans/2026-10-28-aurora-1839-cell-memo.md
Andrey Epifancev a87cb57728 docs(plan): aurora-1839 — план реализации
План: 6 задач исполнителей (haiku: 1–2, sonnet: 3–6), финальное ревью opus,
PR в feat/226-aurora-1837-cell-styles-css и точка ожидания замеров.
Решения стадии плана дописаны в design.md (13–17).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 21:00:07 +04:00

64 KiB
Raw Blame History

AURORA-1839 — мемоизация ячеек таблицы листа: план реализации

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Ветка: feat/234-aurora-1839-cell-memo (Gitea issue #234, Jira AURORA-1839). База стека: feat/226-aurora-1837-cell-styles-css (AURORA-1837, PR #230), которая стоит на feat/211-aurora-1836-row-measure (AURORA-1836, PR #222). PR этой задачи — в feat/226-aurora-1837-cell-styles-css. После слияния #230 в test PR перенацеливается на test с rebase. Worktree: /Users/andrey.epifancev/projects/fomichev_claude/DFiP_Budget_planing/.claude/worktrees/aurora-1839 (индекс codegraph .codegraph/ уже есть).

Goal: включить memoMode: 'cells' в RealtimeTable с явным сбросом мемоизации по раскладке закреплённых колонок и фильтрам, чтобы прокрутка не перерисовывала оставшиеся на экране ячейки.

Architecture: чистая функция ключа сброса (utils/cellMemoReset.js) превращает раскладку закреплённых колонок и фильтры в строку. RealtimeTable строит из неё новый массив tableColumns = [...columns] и передаёт его в MRT. Новый массив даёт новые объекты cell, и мемоизированные ячейки перерисовываются. Перетаскивание ширины в собственном ColumnResizer сообщает MRT columnSizingInfo.isResizingColumn. Пока флаг стоит, MRT замораживает тело таблицы, а ширины закреплённых колонок попадают в ключ один раз, по отпусканию мыши. columnFilters и columnSizingInfo становятся управляемым состоянием RealtimeTable.

Tech Stack: React 18 (JSX), material-react-table 3.2.1, @tanstack/table-core 8.20.5, node --test, Biome 1.9.4; стенд perf/e2e — Playwright 1.64.1 (TypeScript).

Spec: openspec/changes/aurora-1839-cell-memo/ (proposal.md, design.md, specs/sheet-table/spec.md). Исполнитель каждой задачи читает design.md (разделы «Контекст», «Решения дизайна», «Контракт») и требования spec, на которые ссылается задача.

Global Constraints

  • Зависимости не меняются: material-react-table 3.2.1, @tanstack/table-core 8.20.5 из web/package-lock.json. npm install не запускать: web/node_modules — симлинк.
  • Код фронта — JavaScript (JSX), отступ табами, Biome (web/biome.json): noNestedTernary = error, noUnusedVariables/noUnusedImports = error, useExhaustiveDependencies = info.
  • Правки только в web/src/components/RealtimeTable/**, perf/e2e/**, perf/README.md и openspec/changes/aurora-1839-cell-memo/design.md. Не трогать: Cell/** (это AURORA-1890, 1892, 1894), tableColumns.jsx (D5), SummaryTable.jsx, Svod2Page.jsx, api/**.
  • Ключи localStorage настроек колонок (useColumnSettings.js) не меняются.
  • Ключ сброса MUST меняться: закрепление и открепление колонки; скрытие и показ закреплённой; ширина закреплённой (по отпусканию мыши); globalFilter; значение фильтра колонки. Ключ MUST NOT меняться: ширина незакреплённой; скрытие и показ незакреплённой; изменение данных; пока ширину тянут.
  • Пороги производительности (≤ 25 мс на шаг по y и др.) — цель, а не условие слияния. Корректность (контракт, закреплённые колонки, подсветки, состояние ячеек) — жёсткое условие.
  • Контракт (spec, требование «Контракт для правок таблицы листа»): рендер ячейки зависит только от cell. Изменяемое состояние не замыкается в muiTableBodyCellProps, col.Cell, col.Edit. Прокрутка не вызывает setState в RealtimeTable.
  • Коммиты — в стиле репозитория (perf(table): …, perf(e2e): …, docs(…): …), по-русски, git add конкретных файлов. Последняя строка сообщения: Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>.

Правила исполнения волны 2 (обязательны для контроллера и всех сабагентов)

Контроллер передаёт этот раздел целиком в бриф каждого исполнителя и ревьюера.

Замок ночного прогона. С ~01:00 на машине идут замеры производительности, любая нагрузка на CPU их портит.

  • Замок: /private/tmp/claude-501/-Users-andrey-epifancev-projects-fomichev-claude/6fa9c75e-eb11-4e5d-98c1-05eb590ab14f/scratchpad/BENCH_LOCK.
  • Перед каждым тяжёлым шагом нужно дождаться снятия замка: node --test, npm run build, biome по каталогу, запуск фронта или API, docker, pytest, alembic. Команда ожидания (запускать с большим timeout или в фоне с уведомлением, при коротком timeout повторять):
    L=/private/tmp/claude-501/-Users-andrey-epifancev-projects-fomichev-claude/6fa9c75e-eb11-4e5d-98c1-05eb590ab14f/scratchpad/BENCH_LOCK; while [ -e "$L" ]; do sleep 60; done
    
    Ниже в шагах это обозначено как «[замок]».
  • При замке можно: читать код, писать код, npx biome check по 1–3 файлам, node --check по одному файлу, git-коммиты и push.
  • Замеры стенда perf/ не запускать вообще: БД perf в perf-pg, порты 8000/8080/8081/8082, любые npx playwright test в perf/e2e. Их делает главный контроллер. globalSetup Playwright в perf/e2e сбрасывает pg_stat_statements на БД стенда, поэтому даже самопроверки perf/e2e/tests/*.spec.ts не запускать.

Окружение.

  • Фронт: cd web && node --test src/components/RealtimeTable/utils/*.test.js, npx biome check <файлы>, npm run build.
  • Карта кода: перед правкой исполнитель вызывает mcp__codegraph__codegraph_explore по своей области с projectPath = /Users/andrey.epifancev/projects/fomichev_claude/DFiP_Budget_planing/.claude/worktrees/aurora-1839. Если ответ пуст, это один раз отмечается в ledger как O-n, дальше используется Read.
  • Backend не меняется. Полный pytest (PG16, perf-pg:55432, БД dfip_1839) и проверка на PG15 (dfip-pg15:55434, делает главный контроллер) нужны, только если git diff --name-only origin/feat/226-aurora-1837-cell-styles-css...HEAD -- api/ не пуст. Это проверяет задача 8. Python для pytest: /Users/andrey.epifancev/projects/fomichev_claude/.venvs/dfip-api/bin/, свой venv не создавать. Исходные падения test (не наши): test_backend_calls_form3_add_project_and_add_line_functions и два test_export_service_query_params.

Git и PR.

  • Не мержить. Не пушить в test/main. git stash без сообщения не использовать. .bounded не создавать: правки кода разрешает этот план.
  • Push в origin feat/234-aurora-1839-cell-memo после каждой задачи.
  • Решения, принятые без человека (контроллер, исполнитель), дописываются в openspec/changes/aurora-1839-cell-memo/design.md, раздел ## Decisions made without discussion, со сквозной нумерацией (сейчас последний — 17).

Маркировка находок. У каждой находки и каждого решения — маркер источника, он не меняется при переносе между документами:

  • T<n>.<k> — minor-находка ревьюера задачи n;
  • C-n / I-n / M-n — critical / important / minor финального ревью;
  • R-n — рулинг контроллера, рядом номер в design.md: R-1 (design #18);
  • O-n — наблюдение контроллера (неточные отчёты, инструменты, окружение);
  • H-n — замечание человека из PR.

Статус каждой находки — fixed <commit>, deferred или rejected <причина>. Контроллер ведёт в конце ledger раздел ## Findings (| Маркер | Суть | Статус |) и обновляет его при каждой правке.

Review Focus

Пять случаев, которые spec подразумевает, но тесты задач не покрывают целиком. Самые вероятные — выше.

  1. Потерянный mouseup: Alt+Tab посреди перетаскивания, отпускание за пределами окна, размонтирование заголовка. Ожидается, что флаг isResizingColumn снимется (blur, очистка при размонтировании) и таблица снова реагирует на поиск и закрепление. Код — задача 3, идемпотентность endColumnResize — тест задачи 2, ручная проверка — пункт чек-листа задачи 8.
  2. Неполное или старое состояние из localStorage: columnPinning без right, columnVisibility без id колонки, undefined вместо объектов. Ожидается, что ключ не падает, отсутствующий id видимости считается видимой колонкой, а { left } равен { left, right: [] }. Тесты — задача 1.
  3. Очистка фильтров: globalFilter null/undefined, фильтр колонки, удалённый TanStack при пустом значении, диапазон ['', '']. Ожидается: подсветка <mark> снимается во всех видимых ячейках, ключ возвращается к значению «без фильтров». Тесты — задача 1.
  4. Открытый редактор во время сброса: закрепление или смена поиска при открытой ячейке. Ожидается: редактор остаётся открытым в той же ячейке, сохранение по Enter работает (MRT сравнивает editingCell по id). Ручная проверка — чек-лист задачи 8, ревью — задачи 5 и 7.
  5. Сильное сужение колонки при замороженном теле: сужение на 400 px. Ожидается: пустая полоса справа, если она появилась, исчезает по отпусканию мыши (design.md, решение 11). Ручная проверка — чек-лист задачи 8.

Карта файлов

Файл Ответственность Задача
web/src/components/RealtimeTable/utils/cellMemoReset.js (новый) ключ сброса: getPinnedLayoutKey, getFilterResetKey, getCellMemoResetKey, pickSettledSizing 1
web/src/components/RealtimeTable/utils/cellMemoReset.test.js (новый) тесты ключа и тест-допущение @tanstack/table-core 1
web/src/components/RealtimeTable/utils/columnResizeInfo.js (новый) DEFAULT_COLUMN_SIZING_INFO, startColumnResize, endColumnResize 2
web/src/components/RealtimeTable/utils/columnResizeInfo.test.js (новый) тесты помощников против table-core 2
web/src/components/RealtimeTable/TableHead/ColumnResizer.jsx onDragStart/onDragEnd, blur, размонтирование, data-col-resize-id 3
web/src/components/RealtimeTable/TableHead/TableHead.jsx HeaderCell выставляет и снимает isResizingColumn 3
web/src/components/RealtimeTable/RealtimeTable.jsx управляемые columnFilters/columnSizingInfo (4); ключ, tableColumns, memoMode (5) 4, 5
perf/e2e/lib/pinnedGeometry.ts (новый) чистая проверка «закреплённые примыкают» 6
perf/e2e/tests/pinned-geometry.test.ts (новый) node --test для проверки геометрии 6
perf/e2e/scenarios/pinned-resize.spec.ts (новый) сценарий замера перетаскивания ширины закреплённой колонки 6
perf/README.md абзац о новом сценарии 6
openspec/changes/aurora-1839-cell-memo/design.md новые записи «Decisions made without discussion» по ходу исполнения все

Task 1: Ключ сброса мемоизации и тест-допущение table-core

Модель исполнителя: haiku (код готов в плане). Требования spec: «Явный сброс мемоизации проверяется автотестом»; design.md D1, D2, раздел «Тестирование».

Files:

  • Create: web/src/components/RealtimeTable/utils/cellMemoReset.js
  • Test: web/src/components/RealtimeTable/utils/cellMemoReset.test.js

Interfaces:

  • Consumes: ничего.

  • Produces (для задачи 5):

    • getPinnedLayoutKey({ columnPinning, columnVisibility, columnSizing }) -> string
    • getFilterResetKey({ globalFilter, columnFilters }) -> string
    • getCellMemoResetKey({ columnPinning, columnVisibility, columnSizing, globalFilter, columnFilters }) -> string (лишние поля игнорируются)
    • pickSettledSizing({ previous, current, isResizing }) -> object — пока тянут, возвращает previous (та же ссылка), иначе current.
  • Step 1: Написать падающий тест web/src/components/RealtimeTable/utils/cellMemoReset.test.js

import assert from 'node:assert/strict';
import test from 'node:test';
import { createTable, getCoreRowModel } from '@tanstack/table-core';
import { getCellMemoResetKey, getPinnedLayoutKey, pickSettledSizing } from './cellMemoReset.js';

const base = {
	columnPinning: { left: ['mrt-row-expand', 'sort_order'], right: [] },
	columnVisibility: {},
	columnSizing: {},
	globalFilter: '',
	columnFilters: [],
};
const key = (patch = {}) => getCellMemoResetKey({ ...base, ...patch });

test('закрепление колонки слева и справа меняет ключ', () => {
	const k0 = key();
	assert.notEqual(key({ columnPinning: { left: [...base.columnPinning.left, 'data.q1'], right: [] } }), k0);
	assert.notEqual(key({ columnPinning: { left: base.columnPinning.left, right: ['data.q4'] } }), k0);
});

test('открепление колонки меняет ключ', () => {
	const pinned = { left: ['mrt-row-expand', 'sort_order', 'data.q1'], right: [] };
	assert.notEqual(key({ columnPinning: pinned }), key({ columnPinning: base.columnPinning }));
});

test('скрытие и показ закреплённой колонки меняют ключ', () => {
	const visible = key({ columnVisibility: { sort_order: true } });
	const hidden = key({ columnVisibility: { sort_order: false } });
	assert.notEqual(hidden, visible);
	assert.equal(key({ columnVisibility: { sort_order: true } }), visible);
});

test('ширина закреплённой колонки меняет ключ', () => {
	assert.notEqual(key({ columnSizing: { sort_order: 120 } }), key({ columnSizing: { sort_order: 160 } }));
	assert.notEqual(key({ columnSizing: {} }), key({ columnSizing: { sort_order: 160 } }));
});

test('ширина, скрытие и показ незакреплённой колонки не меняют ключ', () => {
	const k0 = key();
	assert.equal(key({ columnSizing: { 'data.q1': 300 } }), k0);
	assert.equal(key({ columnVisibility: { 'data.q1': false } }), k0);
	assert.equal(key({ columnVisibility: { 'data.q1': true } }), k0);
});

test('id без записи в columnVisibility считается видимой колонкой', () => {
	assert.equal(key({ columnVisibility: {} }), key({ columnVisibility: { sort_order: true, 'mrt-row-expand': true } }));
});

test('данные не входят в ключ', () => {
	assert.equal(key({ data: [{ id: 1 }] }), key({ data: [{ id: 2 }] }));
});

test('globalFilter: "" -> "abc" -> "abd" -> ""', () => {
	const [k0, k1, k2, k3] = ['', 'abc', 'abd', ''].map((globalFilter) => key({ globalFilter }));
	assert.notEqual(k1, k0);
	assert.notEqual(k2, k1);
	assert.equal(k3, k0);
});

test('globalFilter null и undefined равны пустой строке', () => {
	assert.equal(key({ globalFilter: null }), key());
	assert.equal(key({ globalFilter: undefined }), key());
});

test('фильтр колонки: значение, диапазон [min, max] и очистка меняют ключ', () => {
	const none = key();
	const keys = [
		none,
		key({ columnFilters: [{ id: 'name', value: 'ab' }] }),
		key({ columnFilters: [{ id: 'name', value: 'abc' }] }),
		key({ columnFilters: [{ id: 'data.q1', value: ['1', ''] }] }),
		key({ columnFilters: [{ id: 'data.q1', value: ['1', '5'] }] }),
	];
	assert.equal(new Set(keys).size, keys.length);
	assert.equal(key({ columnFilters: [] }), none);
});

test('новый массив columnFilters с теми же значениями в другом порядке не меняет ключ', () => {
	const a = { id: 'name', value: 'ab' };
	const b = { id: 'data.q1', value: ['1', '5'] };
	assert.equal(key({ columnFilters: [a, b] }), key({ columnFilters: [{ ...b }, { ...a }] }));
});

test('неполный columnPinning из localStorage не ломает ключ', () => {
	const layout = { columnVisibility: undefined, columnSizing: undefined };
	assert.equal(getPinnedLayoutKey({ ...layout, columnPinning: { left: ['a'] } }), getPinnedLayoutKey({ ...layout, columnPinning: { left: ['a'], right: [] } }));
	assert.doesNotThrow(() => getPinnedLayoutKey({ ...layout, columnPinning: undefined }));
	assert.doesNotThrow(() => getCellMemoResetKey({}));
});

test('пока ширину тянут, ключ прежний; после отпускания — по новым ширинам', () => {
	const s0 = { sort_order: 120 };
	const s1 = { sort_order: 150 };
	const s2 = { sort_order: 180 };
	const settled = pickSettledSizing({ previous: s0, current: s0, isResizing: false });
	const k0 = key({ columnSizing: settled });

	const during = pickSettledSizing({ previous: settled, current: s1, isResizing: true });
	assert.equal(during, s0);
	assert.equal(key({ columnSizing: during }), k0);

	const after = pickSettledSizing({ previous: during, current: s2, isResizing: false });
	assert.equal(after, s2);
	assert.notEqual(key({ columnSizing: after }), k0);
	assert.equal(key({ columnSizing: after }), key({ columnSizing: s2 }));
});

// Допущение @tanstack/table-core 8.20.5, на котором стоит явный сброс
// (openspec/changes/aurora-1839-cell-memo/design.md, «Контекст», п. 3; D1).
// Третий тест показывает, зачем нужен явный сброс: смена закрепления сама ячейки не пересоздаёт.
const createFixture = () =>
	createTable({
		data: [{ a: 1, b: 2, c: 3 }],
		columns: [
			{ accessorKey: 'a', size: 50 },
			{ accessorKey: 'b', size: 100 },
			{ accessorKey: 'c', size: 150 },
		],
		state: { columnPinning: { left: [], right: [] }, columnSizing: {}, columnOrder: [], columnVisibility: {} },
		getCoreRowModel: getCoreRowModel(),
	});
const updateState = (table, state) => table.setOptions((options) => ({ ...options, state: { ...options.state, ...state } }));
const cellsById = (table) => new Map(table.getRowModel().rows[0].getVisibleCells().map((cell) => [cell.id, cell]));

test('table-core: при прежних columns и data объекты ячеек те же', () => {
	const table = createFixture();
	const before = cellsById(table);
	updateState(table, { columnSizing: { a: 80 } });
	for (const [id, cell] of cellsById(table)) assert.equal(cell, before.get(id));
});

test('table-core: новый массив columns с теми же определениями даёт новые ячейки', () => {
	const table = createFixture();
	const before = cellsById(table);
	table.setOptions((options) => ({ ...options, columns: [...options.columns] }));
	const after = cellsById(table);
	assert.equal(after.size, before.size);
	for (const [id, cell] of after) assert.notEqual(cell, before.get(id));
});

test('table-core: смена columnPinning при прежнем массиве columns ячейки не пересоздаёт', () => {
	const table = createFixture();
	const before = cellsById(table);
	updateState(table, { columnPinning: { left: ['c'], right: [] } });
	const after = cellsById(table);
	assert.equal(after.size, before.size);
	for (const [id, cell] of after) assert.equal(cell, before.get(id));
});
  • Step 2: Убедиться, что тест падает. [замок]

Run: cd web && node --test src/components/RealtimeTable/utils/cellMemoReset.test.js Expected: FAIL, Cannot find module '.../cellMemoReset.js'.

  • Step 3: Реализовать web/src/components/RealtimeTable/utils/cellMemoReset.js
// Ключ явного сброса мемоизации ячеек MRT (memoMode: 'cells') в таблице листа.
// Обоснование и контракт: openspec/changes/aurora-1839-cell-memo/ (design.md D1, D2; specs/sheet-table).
// Новое состояние, которое меняет определения колонок или стили закрепления, добавляется сюда и в cellMemoReset.test.js.

const getPinnedSide = (ids, columnVisibility, columnSizing) =>
	(ids ?? []).filter((id) => columnVisibility?.[id] !== false).map((id) => [id, columnSizing?.[id] ?? null]);

// left закреплённой колонки — сумма ширин закреплённых левее, поэтому в ключ входят только
// видимые закреплённые колонки и их ширины. Незакреплённые колонки меняют ширину через CSS-переменные MRT.
export const getPinnedLayoutKey = ({ columnPinning, columnVisibility, columnSizing }) =>
	JSON.stringify([
		getPinnedSide(columnPinning?.left, columnVisibility, columnSizing),
		getPinnedSide(columnPinning?.right, columnVisibility, columnSizing),
	]);

// Порядок фильтров в массиве не важен: пары сортируются по id. Значение диапазона — массив [min, max].
export const getFilterResetKey = ({ globalFilter, columnFilters }) => {
	const filters = (columnFilters ?? []).map(({ id, value }) => [String(id), value ?? null]).sort(([a], [b]) => a.localeCompare(b));
	return JSON.stringify([globalFilter ?? '', filters]);
};

export const getCellMemoResetKey = (state) => JSON.stringify([getPinnedLayoutKey(state), getFilterResetKey(state)]);

// Пока ширину тянут, ключ раскладки строится по ширинам на момент нажатия мыши:
// сброс по ширине закреплённой колонки происходит один раз, по отпусканию (design.md, решение 3).
export const pickSettledSizing = ({ previous, current, isResizing }) => (isResizing ? previous : current);

Если Biome предложит другой формат строк, применить npx biome check --write к этим двум файлам.

  • Step 4: Убедиться, что тест проходит и остальные тесты не сломаны. [замок]

Run: cd web && node --test src/components/RealtimeTable/utils/*.test.js Expected: PASS, fail 0. Все 16 тестов cellMemoReset.test.js проходят. Если падает один из трёх тестов table-core:, значит не выполняется допущение дизайна: исполнитель останавливается и докладывает контроллеру со статусом BLOCKED, тест не правит.

Run: cd web && npx biome check src/components/RealtimeTable/utils/cellMemoReset.js src/components/RealtimeTable/utils/cellMemoReset.test.js Expected: без ошибок.

  • Step 5: Коммит
git add web/src/components/RealtimeTable/utils/cellMemoReset.js web/src/components/RealtimeTable/utils/cellMemoReset.test.js
git commit -F - <<'EOF'
perf(table): ключ сброса мемоизации ячеек и тест допущения table-core

Ключ из раскладки закреплённых колонок и фильтров (AURORA-1839, design.md D2),
pickSettledSizing замораживает ширины, пока колонку тянут.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
git push origin feat/234-aurora-1839-cell-memo

Task 2: Помощники columnSizingInfo для перетаскивания ширины

Модель исполнителя: haiku (код готов в плане). Требования spec: «Закреплённые колонки остаются на своих местах…» (сценарий «Смена ширины закреплённой колонки»); design.md D3.

Files:

  • Create: web/src/components/RealtimeTable/utils/columnResizeInfo.js
  • Test: web/src/components/RealtimeTable/utils/columnResizeInfo.test.js

Interfaces:

  • Consumes: ничего.

  • Produces (для задач 3 и 4):

    • DEFAULT_COLUMN_SIZING_INFO — объект по умолчанию TanStack { startOffset: null, startSize: null, deltaOffset: null, deltaPercentage: null, isResizingColumn: false, columnSizingStart: [] };
    • startColumnResize(columnId: string) -> (old) => newInfo — апдейтер для table.setColumnSizingInfo;
    • endColumnResize(old) -> newInfo — апдейтер; если флаг уже снят, возвращает old (та же ссылка, без лишнего рендера).
  • Step 1: Написать падающий тест web/src/components/RealtimeTable/utils/columnResizeInfo.test.js

import assert from 'node:assert/strict';
import test from 'node:test';
import { createTable, getCoreRowModel } from '@tanstack/table-core';
import { DEFAULT_COLUMN_SIZING_INFO, endColumnResize, startColumnResize } from './columnResizeInfo.js';

test('значение по умолчанию совпадает с table-core', () => {
	const table = createTable({ data: [], columns: [{ accessorKey: 'a' }], state: {}, getCoreRowModel: getCoreRowModel() });
	assert.deepEqual(DEFAULT_COLUMN_SIZING_INFO, table.initialState.columnSizingInfo);
});

test('startColumnResize ставит id колонки и не трогает остальные поля', () => {
	const next = startColumnResize('data.q1')(DEFAULT_COLUMN_SIZING_INFO);
	assert.equal(next.isResizingColumn, 'data.q1');
	assert.deepEqual({ ...next, isResizingColumn: false }, DEFAULT_COLUMN_SIZING_INFO);
	assert.equal(DEFAULT_COLUMN_SIZING_INFO.isResizingColumn, false);
});

test('endColumnResize снимает флаг, повторный вызов возвращает тот же объект', () => {
	const ended = endColumnResize(startColumnResize('a')(DEFAULT_COLUMN_SIZING_INFO));
	assert.equal(ended.isResizingColumn, false);
	assert.equal(endColumnResize(ended), ended);
	assert.equal(endColumnResize(DEFAULT_COLUMN_SIZING_INFO), DEFAULT_COLUMN_SIZING_INFO);
});

test('table.setColumnSizingInfo проводит флаг через управляемое состояние', () => {
	let info = DEFAULT_COLUMN_SIZING_INFO;
	const table = createTable({
		data: [{ a: 1 }],
		columns: [{ accessorKey: 'a' }],
		state: { columnSizingInfo: info },
		onColumnSizingInfoChange: (updater) => {
			info = typeof updater === 'function' ? updater(info) : updater;
		},
		getCoreRowModel: getCoreRowModel(),
	});
	table.setColumnSizingInfo(startColumnResize('a'));
	assert.equal(info.isResizingColumn, 'a');
	table.setColumnSizingInfo(endColumnResize);
	assert.equal(info.isResizingColumn, false);
});
  • Step 2: Убедиться, что тест падает. [замок]

Run: cd web && node --test src/components/RealtimeTable/utils/columnResizeInfo.test.js Expected: FAIL, Cannot find module '.../columnResizeInfo.js'.

  • Step 3: Реализовать web/src/components/RealtimeTable/utils/columnResizeInfo.js
// columnSizingInfo для MRT/TanStack при собственном перетаскивании ширины (TableHead/ColumnResizer).
// Пока isResizingColumn !== false, MRT 3.2.1 не пересоздаёт колонки и замораживает тело таблицы
// (openspec/changes/aurora-1839-cell-memo/design.md, «Контекст» п. 3–4, D3).

export const DEFAULT_COLUMN_SIZING_INFO = {
	startOffset: null,
	startSize: null,
	deltaOffset: null,
	deltaPercentage: null,
	isResizingColumn: false,
	columnSizingStart: [],
};

export const startColumnResize = (columnId) => (old) => ({ ...old, isResizingColumn: columnId });

// Идемпотентно: конец перетаскивания приходит из mouseup, blur окна и размонтирования.
export const endColumnResize = (old) => (old.isResizingColumn === false ? old : { ...old, isResizingColumn: false });
  • Step 4: Убедиться, что тесты проходят. [замок]

Run: cd web && node --test src/components/RealtimeTable/utils/*.test.js Expected: PASS, fail 0.

Run: cd web && npx biome check src/components/RealtimeTable/utils/columnResizeInfo.js src/components/RealtimeTable/utils/columnResizeInfo.test.js Expected: без ошибок.

  • Step 5: Коммит
git add web/src/components/RealtimeTable/utils/columnResizeInfo.js web/src/components/RealtimeTable/utils/columnResizeInfo.test.js
git commit -F - <<'EOF'
perf(table): помощники columnSizingInfo для перетаскивания ширины

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
git push origin feat/234-aurora-1839-cell-memo

Task 3: ColumnResizer сообщает MRT начало и конец перетаскивания

Модель исполнителя: sonnet (интеграция с событиями окна и жизненным циклом React). Требования spec: «Закреплённые колонки остаются на своих местах…»; design.md D3 и «Риски» (потерянный mouseup).

Files:

  • Modify: web/src/components/RealtimeTable/TableHead/ColumnResizer.jsx (весь файл, 61 строка)
  • Modify: web/src/components/RealtimeTable/TableHead/TableHead.jsx:105-127 (HeaderCell), :177 (рендер ColumnResizer)

Interfaces:

  • Consumes: startColumnResize, endColumnResize из ../utils/columnResizeInfo (задача 2); table.setColumnSizingInfo(updater) (TanStack).
  • Produces:
    • ColumnResizer({ columnId, reportDragDeltaX, onDragStart, onDragEnd });
    • на корневом div атрибут data-col-resize-id={columnId}, его использует сценарий задачи 6.

Автотеста у этой задачи нет: в проекте нет тестов React-компонентов. Логика флага покрыта тестами задачи 2, поведение — ручной регресс (задача 8) и ревью.

  • Step 1: Изучить область. codegraph_explore по ColumnResizer, HeaderCell, handleSetColumnWidth с projectPath worktree. Проверить, что ColumnResizer используется только в TableHead.jsx:177.

  • Step 2: Переписать ColumnResizer.jsx. Требования:

    • handleMouseDown (только левая кнопка): isDraggingRef.current = true, setIsDragging(true), запомнить clientX, вызвать onDragStart?.().
    • Единая идемпотентная функция finishDrag (useCallback([])): если isDraggingRef.current ложно — выход; иначе сбросить ref, setIsDragging(false), вызвать onDragEndRef.current?.(). Последний onDragEnd хранится в ref, потому что HeaderCell создаёт колбэки на каждый рендер.
    • Подписки на window в эффекте по isDragging: mousemove → прежний handleMouseMove, mouseup → finishDrag, blur → finishDrag.
    • Отдельный эффект с пустыми зависимостями: очистка при размонтировании вызывает finishDrag(). Очистка эффекта подписок срабатывает при каждой смене handleMouseMove, поэтому onDragEnd в неё класть нельзя.
    • Корневой div получает data-col-resize-id={columnId}, стили не меняются.

    Образец формы (исполнитель может упростить, но не менять поведение):

const ColumnResizer = ({ columnId, reportDragDeltaX, onDragStart, onDragEnd }) => {
	const [isDragging, setIsDragging] = useState(false);
	const isDraggingRef = useRef(false);
	const lastXRef = useRef(0);
	const onDragEndRef = useRef(onDragEnd);
	onDragEndRef.current = onDragEnd;

	const finishDrag = useCallback(() => {
		if (!isDraggingRef.current) return;
		isDraggingRef.current = false;
		setIsDragging(false);
		onDragEndRef.current?.();
	}, []);

	const handleMouseDown = (e) => {
		if (e.button !== 0) return;
		e.preventDefault();
		isDraggingRef.current = true;
		setIsDragging(true);
		lastXRef.current = e.clientX;
		onDragStart?.();
	};

	// handleMouseMove — без изменений

	useEffect(() => {
		if (!isDragging) return;
		window.addEventListener('mousemove', handleMouseMove);
		window.addEventListener('mouseup', finishDrag);
		window.addEventListener('blur', finishDrag);
		return () => {
			window.removeEventListener('mousemove', handleMouseMove);
			window.removeEventListener('mouseup', finishDrag);
			window.removeEventListener('blur', finishDrag);
		};
	}, [isDragging, handleMouseMove, finishDrag]);

	// Размонтирование посреди перетаскивания не должно оставить MRT с isResizingColumn.
	useEffect(() => () => finishDrag(), [finishDrag]);

	return <div data-col-resize-id={columnId} onMouseDown={handleMouseDown} style={/* как сейчас */} />;
};
  • Step 3: Подключить в HeaderCell (TableHead.jsx). Импорт startColumnResize, endColumnResize из ../utils/columnResizeInfo. Рендер на строке 177 заменить на:
<ColumnResizer
	columnId={column.id}
	reportDragDeltaX={handleChangeWidth}
	onDragStart={() => table.setColumnSizingInfo(startColumnResize(column.id))}
	onDragEnd={() => table.setColumnSizingInfo(endColumnResize)}
/>

handleChangeWidth и onChangeWidth не меняются: ширина по-прежнему идёт через handleSetColumnWidth → setColumnSizing на каждое движение.

  • Step 4: Проверка. [замок]

Run: cd web && npx biome check src/components/RealtimeTable/TableHead/ColumnResizer.jsx src/components/RealtimeTable/TableHead/TableHead.jsx Expected: нет новых ошибок относительно базы. Базу снять до правки той же командой и записать в отчёт.

Run: cd web && node --test src/components/RealtimeTable/utils/*.test.js && npm run build Expected: тесты fail 0, сборка завершается без ошибок.

  • Step 5: Коммит
git add web/src/components/RealtimeTable/TableHead/ColumnResizer.jsx web/src/components/RealtimeTable/TableHead/TableHead.jsx
git commit -F - <<'EOF'
perf(table): ColumnResizer сообщает MRT начало и конец перетаскивания ширины

isResizingColumn выставляется на mousedown и снимается по mouseup, blur окна
и размонтированию: пока тянут, MRT замораживает тело таблицы (design.md D3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
git push origin feat/234-aurora-1839-cell-memo

Task 4: Управляемые columnFilters и columnSizingInfo в RealtimeTable

Модель исполнителя: sonnet (интеграция). Требования spec: «Подсветка поиска и фильтров соответствует текущему запросу» (фильтры работают как раньше); design.md D3, D4.

Files:

  • Modify: web/src/components/RealtimeTable/RealtimeTable.jsx: импорты (:19-29), состояние (:89-101), createTableConfig (:325-414), tableConfig (:416-456), проп TableHead (:812).

Interfaces:

  • Consumes: DEFAULT_COLUMN_SIZING_INFO (задача 2).

  • Produces (для задачи 5): в теле RealtimeTable переменные columnFilters (массив { id, value }) и columnSizingInfo (объект TanStack) объявлены до const columns = useMemo(...).

  • Step 1: Изучить область. codegraph_explore по RealtimeTable, createTableConfig, TableHead (проп columnFilters), FilterCell с projectPath worktree. Снять базу biome по RealtimeTable.jsx.

  • Step 2: Состояние. Рядом с rowSelection (:95):

    • const [columnFilters, setColumnFilters] = useState([]);
    • const [columnSizingInfo, setColumnSizingInfo] = useState(DEFAULT_COLUMN_SIZING_INFO);
  • Step 3: Подключить к таблице. В createTableConfig:

    • принять columnFilters и columnSizingInfo параметрами;
    • добавить onColumnFiltersChange: setColumnFilters, onColumnSizingInfoChange: setColumnSizingInfo (сеттеры замыкаются напрямую, как setRowSelection);
    • в state добавить columnFilters и columnSizingInfo.

    В вызов createTableConfig и в зависимости useMemo tableConfig добавить columnFilters и columnSizingInfo.

  • Step 4: TableHead. columnFilters={table.getState().columnFilters} (:812) заменить на columnFilters={columnFilters}.

  • Step 5: Проверка. [замок]

Run: cd web && npx biome check src/components/RealtimeTable/RealtimeTable.jsx Expected: нет новых ошибок относительно базы.

Run: cd web && node --test src/components/RealtimeTable/utils/*.test.js && npm run build Expected: fail 0, сборка без ошибок.

Самопроверка по коду (записать в отчёт): grep -n "columnFilters\|columnSizingInfo" web/src/components/RealtimeTable/RealtimeTable.jsx показывает useState, state, on…Change, зависимости tableConfig и проп TableHead. Других чтений table.getState().columnFilters в RealtimeTable.jsx нет.

  • Step 6: Коммит
git add web/src/components/RealtimeTable/RealtimeTable.jsx
git commit -F - <<'EOF'
perf(table): управляемые columnFilters и columnSizingInfo в RealtimeTable

Значения нужны до создания таблицы, чтобы войти в ключ сброса мемоизации (design.md D3, D4).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
git push origin feat/234-aurora-1839-cell-memo

Task 5: memoMode: 'cells' с явным сбросом через tableColumns

Модель исполнителя: sonnet (интеграция; ядро логики уже покрыто тестами задачи 1). Требования spec: «Прокрутка не перерисовывает ячейки, оставшиеся на экране», «Закреплённые колонки остаются на своих местах…», «Подсветка поиска…», «Контракт…»; design.md решения 1–4, D1, D2, «Контракт».

Files:

  • Modify: web/src/components/RealtimeTable/RealtimeTable.jsx: импорты; блок сразу после const columns = useMemo(...) (:255-273); createTableConfig (:344-347); вызов и зависимости tableConfig (:416-456).

Interfaces:

  • Consumes: getCellMemoResetKey, pickSettledSizing (задача 1); columnFilters, columnSizingInfo (задача 4); columnPinning, columnVisibility, columnSizing (useColumnSettings); globalFilter.

  • Produces: tableColumns — массив, который уходит в MRT. SettingsPanel и TableHead продолжают получать columns.

  • Step 1: Изучить область. codegraph_explore по RealtimeTable (columns, tableConfig, useMaterialReactTable), getCachedCellProps в tableColumns.jsx (только чтение, файл не меняется) с projectPath worktree.

  • Step 2: Ключ и tableColumns. Сразу после const columns = useMemo(...):

// Мемоизация ячеек MRT (memoMode: 'cells') сравнивает только объект cell. Состояние вне данных строки,
// от которого зависят стили закрепления и подсветка фильтров, сбрасывает её новым массивом колонок.
// Контракт для правок таблицы: openspec/changes/aurora-1839-cell-memo/specs/sheet-table/spec.md
// (после архивации — openspec/specs/sheet-table/spec.md), чек-лист ревью — design.md, «Контракт».
const isResizingColumn = Boolean(columnSizingInfo.isResizingColumn);
const settledColumnSizingRef = useRef(columnSizing);
const settledColumnSizing = pickSettledSizing({
	previous: settledColumnSizingRef.current,
	current: columnSizing,
	isResizing: isResizingColumn,
});
useEffect(() => {
	settledColumnSizingRef.current = settledColumnSizing;
}, [settledColumnSizing]);

const cellMemoResetKey = useMemo(
	() => getCellMemoResetKey({ columnPinning, columnVisibility, columnSizing: settledColumnSizing, globalFilter, columnFilters }),
	[columnPinning, columnVisibility, settledColumnSizing, globalFilter, columnFilters],
);

// biome-ignore lint/correctness/useExhaustiveDependencies: cellMemoResetKey намеренно в зависимостях — новый массив сбрасывает мемоизацию ячеек MRT
const tableColumns = useMemo(() => [...columns], [columns, cellMemoResetKey]);

Ref пишется в эффекте (фаза commit), а не в рендере. На mousedown в previous лежат ширины последнего зафиксированного рендера, то есть до перетаскивания.

  • Step 3: Передать в таблицу.

    • В createTableConfig сразу после ...BASE_TABLE_CONFIG добавить memoMode: 'cells',.
    • В вызове createTableConfig передать columns: tableColumns.
    • В зависимостях tableConfig заменить columns на tableColumns.
    • Остальные использования columns (SettingsPanel, TableHead, isLoadingData, эффект measure на :287-295) не трогать.
  • Step 4: Проверка контракта по коду. Исполнитель проходит чек-лист ревью из design.md («Контракт»), результат по каждому пункту — в отчёт:

    • в muiTableBodyCellProps, muiTableBodyRowProps, col.Cell, col.Edit нет новых замыканий на изменяемые переменные рендера;
    • нет setState по scroll в RealtimeTable и его хуках;
    • tableColumns пересоздаётся только по columns и cellMemoResetKey.
  • Step 5: Проверка. [замок]

Run: cd web && npx biome check src/components/RealtimeTable/RealtimeTable.jsx Expected: нет новых ошибок и предупреждений относительно базы задачи 4. biome-ignore принят: Biome не сообщает о неиспользуемом подавлении.

Run: cd web && node --test src/components/RealtimeTable/utils/*.test.js && npm run build Expected: fail 0, сборка без ошибок.

  • Step 6: Коммит
git add web/src/components/RealtimeTable/RealtimeTable.jsx
git commit -F - <<'EOF'
perf(table): memoMode 'cells' с явным сбросом по раскладке закреплённых и фильтрам

tableColumns = [...columns] пересоздаётся по ключу сброса (utils/cellMemoReset.js);
ширины закреплённых колонок входят в ключ по отпусканию мыши (design.md D1, D2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
git push origin feat/234-aurora-1839-cell-memo

Task 6: Сценарий perf/e2e/scenarios/pinned-resize.spec.ts (отдельный коммит, без запуска замеров)

Модель исполнителя: sonnet (Playwright и CDP по образцу scroll.spec.ts; чистая проверка геометрии — готовый код). Требования spec: «Перетаскивание ширины закреплённой колонки не медленнее базы»; design.md D6 и «Замер до и после» → «Сценарий … (добавить)», «Атрибут ресайзера в базе».

Сценарий не запускается ни исполнителем, ни контроллером SDD: замеры делает главный контроллер (см. задачу 8). Коммит — только файлы perf/, отдельно от кода фронта.

Files:

  • Create: perf/e2e/lib/pinnedGeometry.ts
  • Create: perf/e2e/tests/pinned-geometry.test.ts (node --test; в Playwright не попадает, его testMatch — *.spec.ts)
  • Create: perf/e2e/scenarios/pinned-resize.spec.ts
  • Modify: perf/README.md — короткий раздел рядом с описанием scroll.spec.ts/hotspots.spec.ts

Interfaces:

  • Consumes: loginAs, WEB (lib/auth); PROBE_SCRIPT, startProbe, perStep (lib/probe); throttle (lib/cdp); attachNetlog (lib/netlog); append (lib/results); loadManifest, scale (lib/manifest); атрибуты фронта data-col-select-id, data-col-resize-id (задача 3), [data-pin-panel] button[aria-label="Закрепить область"] с data-active, td[data-pinned="true"].

  • Produces: записи results/<run_id>/pinned_resize.jsonl: scenario: 'pinned_resize', target: '<FORM>/<SHEET>/<direction>@pinned', role: 'rf', метрики ...probe, ...perStep(probe, steps), steps.

    • findPinnedGap(cells: Array<{ left: number; width: number }>, tolerancePx = 1): number — индекс i первой пары, где |left[i+1] − (left[i] + width[i])| > tolerancePx; если пар нет или все примыкают, -1.
  • Step 1: Написать падающий тест perf/e2e/tests/pinned-geometry.test.ts

import assert from 'node:assert/strict';
import test from 'node:test';
import { findPinnedGap } from '../lib/pinnedGeometry.ts';

test('примыкающие закреплённые колонки: -1', () => {
  assert.equal(findPinnedGap([{ left: 0, width: 40 }, { left: 40, width: 120 }, { left: 160, width: 300 }]), -1);
});

test('наложение после смены ширины: индекс левой колонки пары', () => {
  assert.equal(findPinnedGap([{ left: 0, width: 40 }, { left: 40, width: 200 }, { left: 160, width: 300 }]), 1);
});

test('зазор: индекс левой колонки пары', () => {
  assert.equal(findPinnedGap([{ left: 0, width: 40 }, { left: 60, width: 120 }]), 0);
});

test('дробные пиксели в пределах допуска 1 px: -1', () => {
  assert.equal(findPinnedGap([{ left: 0, width: 40.4 }, { left: 41.2, width: 10 }]), -1);
});

test('меньше двух колонок: -1', () => {
  assert.equal(findPinnedGap([]), -1);
  assert.equal(findPinnedGap([{ left: 0, width: 40 }]), -1);
});
  • Step 2: Убедиться, что тест падает. [замок]

Run: node --test perf/e2e/tests/pinned-geometry.test.ts Expected: FAIL, модуль ../lib/pinnedGeometry.ts не найден.

  • Step 3: Реализовать perf/e2e/lib/pinnedGeometry.ts — без импортов, чтобы его загружал и node --test, и Playwright:
/** Проверка раскладки закреплённых колонок (spec sheet-table): ячейки td[data-pinned] одной строки по порядку примыкают. */
export interface PinnedCellRect { left: number; width: number }

export function findPinnedGap(cells: PinnedCellRect[], tolerancePx = 1): number {
  for (let i = 0; i + 1 < cells.length; i++) {
    if (Math.abs(cells[i + 1].left - (cells[i].left + cells[i].width)) > tolerancePx) return i;
  }
  return -1;
}

Run: node --test perf/e2e/tests/pinned-geometry.test.ts [замок] Expected: PASS, 5 тестов.

  • Step 4: Написать сценарий perf/e2e/scenarios/pinned-resize.spec.ts по образцу scroll.spec.ts. Шапка — комментарий с параметрами env, как у scroll.spec.ts. Решения, которые исполнитель не меняет:

    • Параметры: PERF_TARGETS (glob, по умолчанию 'FORM_2/AHR/*'), PERF_REPEATS (по умолчанию 3), PERF_CPU_RATES (по умолчанию '1'), PERF_LOAD_TIMEOUT_MS. Роль только rf. Цели — листы манифеста с owner === 'rf', имя ${form_type}/${sheet}/${direction ?? 'null'}; цель берётся, если glob совпадает с именем или с ${имя}@pinned.
    • Контекст: browser.newContext({ viewport: { width: 1600, height: 900 } }), attachNetlog (scenario: 'pinned_resize') и nl.flush() в finally, loginAs, addInitScript(PROBE_SCRIPT).
    • Раскладка по умолчанию: до навигации ctx.addInitScript удаляет из localStorage все ключи, которые начинаются с ${form_id}_${form_type}_${sheet}_ и оканчиваются на _columnPinning, _columnSizing или _columnVisibility. По префиксу, потому что direction в ключе бывает строкой null/undefined.
    • Загрузка: page.goto(WEB + sheet.url), ожидание ≥ 20 строк .MuiTableContainer-root tbody tr с таймаутом PERF_LOAD_TIMEOUT_MS, затем waitForTimeout(500) и throttle(page, cpu). Если строк не дождались — запись incomplete: true с skip_reason, как в scroll.spec.ts.
    • Колонка: первый [data-col-select-id] в DOM-порядке, id которого начинается с data.. Если такого нет — первый, чей id не mrt-row-expand и не sort_order. Клик по нему, затем клик [data-pin-panel] button[aria-label="Закрепить область"], ожидание data-active="true" на этой кнопке.
    • Ресайзер: [data-col-resize-id="<id>"]. Если его нет (сборка базы AURORA-1837), запасной путь в page.evaluate: среди div с style.cursor === 'col-resize' взять те, чей getBoundingClientRect().right отличается от right у [data-col-select-id="<id>"] не больше чем на 2 px и чей top больше top строки букв; из них — с наибольшим top (строка листовых заголовков). Если не нашли — incomplete: true, skip_reason: 'resizer_not_found'.
    • Перетаскивание: page.mouse.move в центр ресайзера, page.mouse.down(), const pr = await startProbe(page). Затем 50 шагов +4 px и 50 шагов −4 px по x: page.mouse.move(x, y) и после каждого шага ожидание кадра page.evaluate(() => new Promise<void>((r) => requestAnimationFrame(() => r()))). Потом page.mouse.up(), ещё два кадра, const probe = await pr.stop(). steps = 100.
    • Геометрия: в page.evaluate найти первую tbody tr, в которой не меньше двух td[data-pinned="true"], и вернуть { left, width } из getBoundingClientRect() по DOM-порядку. На стороне Node проверить findPinnedGap(rects). Результат ≥ 0 → incomplete: true, skip_reason: 'pinned_overlap' и в метрики pinned_gap_index.
    • Запись: append({ scenario: 'pinned_resize', target: \${name}@pinned`, role: 'rf', scale: scale(), cpu_rate: cpu, repeat, metrics: { ...probe, ...perStep(probe, steps), steps }, artifacts: [], incomplete, skip_reason }). Режим PERF_HOT_MODE=react` не делается.
    • Тесты Playwright: test.describe('pinned_resize', …): на каждую цель × CPU × повтор — test(\${name} rf pinned cpu${cpu} #${repeat}`), test.setTimeout(PERF_TEST_TIMEOUT_MS ?? 45 мин)`.
  • Step 5: Проверка без запуска замеров.

Run: node --check perf/e2e/scenarios/pinned-resize.spec.ts && node --check perf/e2e/lib/pinnedGeometry.ts Expected: код возврата 0. Это синтаксис TS через strip-types Node 24. Типы не проверяются: в worktree нет perf/e2e/node_modules.

Run: node --test perf/e2e/tests/pinned-geometry.test.ts [замок] Expected: PASS.

npx playwright test не запускать: правило волны и globalSetup против БД стенда.

  • Step 6: perf/README.md. Раздел из трёх-пяти строк: что делает сценарий, команда из design.md («Замер до и после», строка «Перетаскивание ширины закреплённой»), метрики (main_thread_ms_per_step на шаг мыши, frame_p95_ms, heavy_frame_n), skip_reason: pinned_overlap, запасной поиск ресайзера для сборки базы, самопроверка node --test perf/e2e/tests/pinned-geometry.test.ts.

  • Step 7: Коммит (только perf/)

git add perf/e2e/lib/pinnedGeometry.ts perf/e2e/tests/pinned-geometry.test.ts perf/e2e/scenarios/pinned-resize.spec.ts perf/README.md
git commit -F - <<'EOF'
perf(e2e): сценарий pinned-resize — перетаскивание ширины закреплённой колонки

Закрепляет первую колонку данных FORM_2/AHR (rf), тянет её границу 50×(+4 px) и 50×(−4 px)
с ожиданием кадра, после отпускания проверяет примыкание td[data-pinned].
Для сборки базы (без data-col-resize-id) — запасной поиск ресайзера. Замеры не запускались.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
git push origin feat/234-aurora-1839-cell-memo

Task 7: Финальное ревью всей ветки

Модель ревьюера: opus. Контроллер SDD передаёт в бриф раздел «Правила исполнения волны 2», путь spec и этот план.

  • Step 1: Диапазон. Ревью — git diff origin/feat/226-aurora-1837-cell-styles-css...HEAD (только коммиты этой задачи, без 1836/1837).
  • Step 2: Что проверяет ревьюер.
    • Каждое требование specs/sheet-table/spec.md и пункт «Global Constraints» соотнесены с кодом.
    • Контракт по чек-листу design.md («Контракт»).
    • Пять пунктов «Review Focus» этого плана.
    • Сценарий задачи 6 соответствует design.md («Сценарий … (добавить)») и не ломает агрегацию perfkit.aggregate: поля scenario/target/role/scale/cpu_rate на месте.
    • Не тронуты Cell/**, tableColumns.jsx, своды, api/**.
  • Step 3: Проверки. [замок] Ревьюер запускает cd web && node --test src/components/RealtimeTable/utils/*.test.js, node --test perf/e2e/tests/pinned-geometry.test.ts, cd web && npx biome check src/components/RealtimeTable и npm run build.
  • Step 4: Находки. Находки — C-n/I-n/M-n. Critical и important исправляет исполнитель sonnet в той же ветке, отдельными коммитами fix(table): … с Co-Authored-By. Minor исправляются или получают deferred/rejected <причина>. После исправлений — повторная проверка шага 3. Таблица находок — в ledger, раздел ## Findings.

Task 8: PR, точка ожидания замеров

Исполнитель: контроллер SDD (sonnet).

  • Step 1: Backend. git diff --name-only origin/feat/226-aurora-1837-cell-styles-css...HEAD -- api/ должен быть пуст. Тогда pytest и PG15 не нужны, в PR пишется «backend не менялся». Если не пуст, значит нарушены Global Constraints: остановиться и доложить.

  • Step 2: Описание PR (файл в scratchpad, например /private/tmp/claude-501/-Users-andrey-epifancev-projects-fomichev-claude/6fa9c75e-eb11-4e5d-98c1-05eb590ab14f/scratchpad/pr-234.md). Состав:

    • Closes #234, Jira AURORA-1839. Стек: база feat/226-aurora-1837-cell-styles-css (PR #230), после его слияния — rebase на test.
    • Что сделано: по одному пункту на задачи 1–6.
    • Тесты: команды и результаты node --test (web и perf/e2e/tests/pinned-geometry.test.ts), biome, npm run build. Backend не менялся.
    • Чек-лист ручного регресса — design.md «Тестирование» → «Ручной регресс», дословно, с - [ ], плюс:
      • Alt+Tab посреди перетаскивания ширины — после возврата таблица реагирует на поиск и закрепление (Review Focus 1);
      • закрепление и смена поиска при открытом редакторе — редактор на месте, Enter сохраняет (Review Focus 4);
      • сужение широкой колонки на 400 px — полоса исчезает по отпусканию (Review Focus 5);
      • уже отрисованные ячейки обновляются без прокрутки при поиске, фильтре, закреплении, скрытии закреплённой, ширине закреплённой.
    • «Замер до/после — добавит контроллер после прогона на стенде»: таблица design.md «Замер до и после» с пустыми колонками «до»/«после». Пороги — цель, а не условие слияния.
    • «Rulings I made» — список R-n (design #N) из финального отчёта контроллера.
    • Таблица всех находок: маркер, суть, статус. deferred перечислены отдельно.
    • Последняя строка: 🤖 Generated with [Claude Code](https://claude.com/claude-code).
  • Step 3: Создать PR.

tea pulls create --base feat/226-aurora-1837-cell-styles-css --head feat/234-aurora-1839-cell-memo \
  --title "AURORA-1839: мемоизация ячеек таблицы листа" --description "$(cat <путь pr-234.md>)"

Expected: номер PR. Не мержить.

  • Step 4: ТОЧКА ОЖИДАНИЯ — замеры. Контроллер SDD на этом останавливается и отдаёт финальный отчёт главной сессии: номер PR, SHA HEAD ветки и базы, таблица находок, рулинги. Дальше работает главный контроллер, вне SDD:

    1. После основных ночных замеров прогоняет все строки таблицы design.md «Замер до и после», включая scenarios/pinned-resize.spec.ts, на сборках «до» (HEAD feat/226-aurora-1837-cell-styles-css) и «после» (HEAD этой ветки). Соблюдает ловушки стенда из того же раздела.
    2. Дописывает цифры в PR (комментарий tea comment <PR> … или правка описания).
    3. Если цель не достигнута (шаг y > 25 мс, регрессия x, соавтора или pinned-resize относительно базы), заводит follow-up issue с разбором, что съедает остаток. Это не блокирует PR.
    4. Если pinned-resize дал skip_reason: 'pinned_overlap', это нарушение корректности, и оно блокирует слияние. Исправление идёт в этой ветке.
  • Step 5: Ручной регресс и слияние. Отметки чек-листа в PR — до слияния. Корректность — жёсткое условие. Замечания человека из PR (H-n) обрабатываются через superpowers:receiving-code-review в этой ветке. Merge — только по явной команде человека.