AURORA-1839: мемоизация ячеек таблицы листа #240
840
docs/superpowers/plans/2026-10-28-aurora-1839-cell-memo.md
Normal file
840
docs/superpowers/plans/2026-10-28-aurora-1839-cell-memo.md
Normal file
@ -0,0 +1,840 @@
|
||||
# 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 повторять):
|
||||
```bash
|
||||
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`
|
||||
|
||||
```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`
|
||||
|
||||
```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: Коммит**
|
||||
|
||||
```bash
|
||||
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`
|
||||
|
||||
```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`
|
||||
|
||||
```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: Коммит**
|
||||
|
||||
```bash
|
||||
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}`, стили не меняются.
|
||||
|
||||
Образец формы (исполнитель может упростить, но не менять поведение):
|
||||
|
||||
```jsx
|
||||
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 заменить на:
|
||||
|
||||
```jsx
|
||||
<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: Коммит**
|
||||
|
||||
```bash
|
||||
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: Коммит**
|
||||
|
||||
```bash
|
||||
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(...)`:
|
||||
|
||||
```js
|
||||
// Мемоизация ячеек 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: Коммит**
|
||||
|
||||
```bash
|
||||
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`
|
||||
|
||||
```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:
|
||||
|
||||
```ts
|
||||
/** Проверка раскладки закреплённых колонок (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/`)**
|
||||
|
||||
```bash
|
||||
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.**
|
||||
|
||||
```bash
|
||||
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 — только по явной команде человека.
|
||||
@ -411,3 +411,24 @@ const pinnedLayoutKey = useMemo(
|
||||
- Сценарий `pinned-resize.spec.ts` идёт в этом же PR отдельным коммитом (D6).
|
||||
- Обоснование: стенд занят ночным прогоном, а пороги не блокируют слияние (решение человека 8).
|
||||
- Цена ошибки: если замер покажет регрессию, исправление пойдёт уже после открытия PR, возможно отдельным PR по follow-up.
|
||||
13. **2026-10-28 (план) — ядро заморозки ширин — чистая `pickSettledSizing({ previous, current, isResizing })` в `utils/cellMemoReset.js`. `RealtimeTable` хранит «устоявшиеся» ширины в `ref`, который обновляется в эффекте (фаза commit). Это вместо `useMemo` с зависимостью `isResizingColumn ? null : columnSizing` из D2.**
|
||||
- Обоснование: функция покрыта тестом и сама используется в коде, поэтому тест проверяет рабочий путь, а не копию логики. Запись `ref` в эффекте безопасна при прерванных рендерах React.
|
||||
- Цена ошибки: ключ раскладки обновится на рендер позже или раньше, то есть возможен лишний или пропущенный сброс по отпусканию мыши. Это видно на ручной проверке «ширина закреплённой». Откат — вариант из D2, несколько строк.
|
||||
14. **2026-10-28 (план) — апдейтеры `columnSizingInfo` вынесены в `utils/columnResizeInfo.js`: `DEFAULT_COLUMN_SIZING_INFO`, `startColumnResize(id)`, идемпотентный `endColumnResize`. Тест сверяет значение по умолчанию с `table-core`.**
|
||||
- Обоснование: конец перетаскивания приходит из трёх мест (`mouseup`, `blur`, размонтирование). Идемпотентный апдейтер не даёт лишнего рендера. Значение по умолчанию не разойдётся с TanStack незаметно.
|
||||
- Цена ошибки: лишний модуль из трёх экспортов.
|
||||
15. **2026-10-28 (план) — проверка геометрии сценария `pinned-resize` — чистая функция `perf/e2e/lib/pinnedGeometry.ts` (`findPinnedGap`). Тест — `perf/e2e/tests/pinned-geometry.test.ts` на `node --test` (strip-types Node 24), а не Playwright-спека.**
|
||||
- Обоснование: прогон Playwright в `perf/e2e` запускает `globalSetup`, а тот сбрасывает `pg_stat_statements` на БД стенда. Правило волны запрещает трогать стенд кому-либо, кроме главного контроллера. `testMatch` Playwright (`*.spec.ts`) этот файл не подхватывает.
|
||||
- Цена ошибки: в `perf/e2e` появляется второй вид самопроверки (`node --test`). Синтаксис сценария проверяется только `node --check`, типы — нет: в worktree нет `perf/e2e/node_modules`. Ошибку типов покажет первый прогон у главного контроллера.
|
||||
16. **2026-10-28 (план) — параметры сценария `pinned-resize`:**
|
||||
- по умолчанию `PERF_TARGETS='FORM_2/AHR/*'`;
|
||||
- ключи раскладки в localStorage чистятся по префиксу `${form_id}_${form_type}_${sheet}_` и суффиксам `_columnPinning`/`_columnSizing`/`_columnVisibility`;
|
||||
- режим `PERF_HOT_MODE=react` не делается.
|
||||
- Обоснование: критерий spec — только FORM_2/AHR. `direction` в ключе localStorage бывает строкой `null` или `undefined`, поэтому точный ключ ненадёжен. React-режим в design.md отмечен как опциональный.
|
||||
- Цена ошибки: число `Styled(td)` на шаг перетаскивания придётся снимать отдельной доработкой сценария. Широкий префикс может стереть раскладку другого `direction` того же листа, но только в браузерном контексте замера.
|
||||
17. **2026-10-28 (план) — порядок завершения:**
|
||||
- PR открывается после финального ревью (opus) с чек-листом ручного регресса, а сам регресс выполняется по чек-листу в PR до слияния;
|
||||
- pytest и проверка PG15 не запускаются, если `git diff` относительно базы не затрагивает `api/`;
|
||||
- цифры замеров главный контроллер дописывает в PR после точки ожидания.
|
||||
- Обоснование: решение 12 и требование «PR открывается с кодом, тестами и чек-листом». Backend в задаче не меняется.
|
||||
- Цена ошибки: если ручной регресс найдёт нарушение корректности, исправление пойдёт коммитами в уже открытый PR.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user