Три фазы — три PR стеком: сервер (SQL, API, тесты-оракулы первыми), фронт (окно страниц в той же таблице), стенд замеров. Решения плана дописаны в design.md (#23–#34). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
97 KiB
AURORA-1843 — свод 2: серверные страницы, поиск, фильтры и компактный ответ. План реализации
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.
Goal: Свод 2 (/svod/2) получает строки страницами с сервера, поиск, три колоночных фильтра и их варианты считает сервер, а в памяти вкладки держится окно строк. Для пользователя поведение экрана не меняется (решение человека 9 от 28.10).
Architecture:
-
Сервер. Одна миграция
0053:- локаленезависимые нормализаторы
svod2_casefold,svod2_js_trimиsvod2_js_number_text, плюс проекцияsvod2_compact; - общий отбор
svod2_lines; - новые
v_svod2_go/v_svod2_rfс поиском, фильтрами иtotal_count; v_svod2_facets.
Обработчик переходит в новый режим, только если в запросе есть хотя бы один новый параметр. Без них ответ байт-в-байт прежний.
- локаленезависимые нормализаторы
-
Фронт. Та же таблица MRT получает срез окна и отступы виртуализатора. Чистая логика лежит в
svod2Paging.js(функции окна и редьюсер), хукuseSvod2Pages.jsходит в API. Варианты фильтров приходят с сервера через переопределённыйgetFacetedUniqueValues. -
Корректность доказывают оракулы. Дословная JS-копия правил экрана «до» (
perf/analysis/svod2_search_oracle.mjs) порождает векторы. Python-порт проверяется на этих векторах и служит оракулом интеграционных тестов API на засеянных данных. На стенде тот же JS-оракул сверяет сервер с прежним полным ответом.
Tech Stack: Python 3.12, FastAPI 0.111, SQLAlchemy 2 async + asyncpg, Alembic, PL/pgSQL / SQL (PostgreSQL 15/16), pytest; React 18, MRT 3.2.1, TanStack Table 8.20.5 / virtual-core 3.11.2, axios, node --test (Node 24), Biome; Playwright (стенд perf/).
Spec: openspec/changes/aurora-1843-svod2-server-paging/ (proposal.md, design.md, specs/summary2-api/spec.md, specs/summary2-screen/spec.md)
Issue: Gitea #236, Jira AURORA-1843 (эпик AURORA-1834).
Ветки (три фазы = три PR, стеком):
| фаза | ветка | база PR | от чего ответвляется |
|---|---|---|---|
| 1 — сервер | feat/236-aurora-1843-svod2-server-paging |
feat/220-aurora-1842-svod2-column-virtual (PR #227) |
вершина 1842 399234a + коммиты OpenSpec |
| 2 — фронт | feat/236-aurora-1843-svod2-server-paging-web |
feat/236-aurora-1843-svod2-server-paging |
вершина ветки фазы 1 после Task 8 |
| 3 — стенд | feat/236-aurora-1843-svod2-server-paging-perf |
feat/236-aurora-1843-svod2-server-paging-web |
вершина ветки фазы 2 после Task 13 |
База стека: feat/220-aurora-1842-svod2-column-virtual @ 399234a (AURORA-1842, PR #227), она стоит на AURORA-1835 (PR #217) → test.
Worktree: /Users/andrey.epifancev/projects/fomichev_claude/DFiP_Budget_planing/.claude/worktrees/aurora-1843 (дальше $WT). Все три ветки ведутся в нём последовательно (git switch -c).
Global Constraints
Замок ночного прогона (правило волны 2).
-
Перед каждым тяжёлым шагом исполнитель выполняет
WAIT_LOCKи ждёт снятия замка:- тяжёлые шаги — pytest, alembic,
npm run build,node --test, biome по многим файлам, запуск API или фронта, docker; - команду ожидания запускать с большим 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 - тяжёлые шаги — pytest, alembic,
-
Читать и писать код и коммитить можно и при замке.
-
Контроллер SDD передаёт это правило дословно в бриф каждого исполнителя и ревьюера.
Стенд perf/ исполнителям не запускать вообще: БД perf, порты 8000/8080/8081/8082, perf/stand.sh, perfkit.*, Playwright-сценарии. Замеры, reset стенда, ручную проверку и сверку на стенде делает главный контроллер.
Окружение тестов.
-
Python — только
/Users/andrey.epifancev/projects/fomichev_claude/.venvs/dfip-api/bin/(pytest, alembic). Свой venv не создавать. -
API_TEST <пути>— интеграционный прогон на PG16 (perf-pg, порт 55432, своя БДdfip_1843). БД пересоздаётся перед каждым прогоном с--init_db. Дословно:docker exec perf-pg dropdb -U perf --if-exists dfip_1843 && docker exec perf-pg createdb -U perf dfip_1843 && cd $WT/api && DATABASE_URL=postgresql+asyncpg://perf:perf@localhost:55432/dfip_1843 /Users/andrey.epifancev/projects/fomichev_claude/.venvs/dfip-api/bin/pytest <пути> --init_db -
UNIT_TEST <пути>—cd $WT/api && /Users/andrey.epifancev/projects/fomichev_claude/.venvs/dfip-api/bin/pytest <пути>, без БД. -
БД
perfне трогать никогда. -
Исходное состояние полного набора — 3 падения, которые не наши:
test_backend_calls_form3_add_project_and_add_line_functions;- два теста в
test_export_service_query_params.
Новых падений быть не должно.
-
PG15.12 (
dfip-pg15, порт 55434) проверяет главный контроллер (шаги «передать контроллеру» в Task 8). -
Фронт:
web/node_modules— симлинк,npm installне запускать. Доступныcd $WT/web && node --test <файлы>,npx biome check <файлы>,npm run build. -
perf/e2e/node_modulesу исполнителя нет. TypeScript-логику стенда выносить в чистые модули без импортов и только со стираемым синтаксисом (безenum,namespace, parameter properties). Тесты к ним —node --test perf/e2e/tests/<файл>.test.ts: Node 24 сам стирает типы. Playwright-прогоны и проверку типов делает контроллер. -
Python-тесты стенда:
cd $WT/perf && /Users/andrey.epifancev/projects/fomichev_claude/.venvs/dfip-api/bin/pytest tests/<файл>. -
Карта кода:
mcp__codegraph__codegraph_exploreсprojectPath=$WT. Implementer вызывает его по своей области перед изменением.
Правила кода и данных.
- Совместимость с PostgreSQL 15: без
any_value, конструкторов SQL/JSON,IS JSONиJSON_TABLE; у подзапросов вFROMнужны алиасы. v3.v_svod2_go_exportиSummaryRepository.get_svod2_rowsне менять.ROW_VIRTUALIZER_OPTIONSвSvod2Page.jsx:26-31не править: это hunk AURORA-1836. Опции собираются в компоненте.SvodContext,SvodFiltersи свод 1 не менять.- Эталон
api/tests/fixtures/svod2_legacy_golden.jsonпишется один раз в Task 3, на коде «до». Потом его не перезаписывать: если тест эталона упал, ищется причина в коде, а не меняется эталон. - Семантику экрана не «улучшать»: перекрытие полей
contract_summary, поиск по техническим id и запись чисел в JS сохраняются. Находки по ним —deferred. - Миграция: рабочий номер
0053,down_revision = "0049". Окончательный номер «голова + 1» выбирается при rebase (design, «Совместимость»).
Коммиты и PR.
- Сообщения коммитов по-русски, в стиле репозитория:
perf(api): …,perf(web): …,test(api): …,perf: …дляperf/,docs(…). git addтолько конкретных файлов. Последняя строка сообщения —Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>..boundedне создавать.git stashбез тега не использовать. Не мержить, не пушить вtest/main.- Между фазами остановок для человека нет: PR создаётся, и сразу начинается следующая фаза. Merge — только по команде человека.
- Решения без обсуждения дописываются в
openspec/changes/aurora-1843-svod2-server-paging/design.md→## Decisions made without discussion: дата, решение, обоснование, цена ошибки. Нумерация продолжается с 35.
Маркировка находок.
T<n>.<k>— minor-находка ревьюера задачи n. Номера задач сквозные по плану, 1–18.C-n/I-n/M-n— финальное ревью ветки. Нумерация сквозная по фиче: фаза 2 продолжает номера фазы 1, фаза 3 — номера фазы 2.R-n (design #m)— рулинг контроллера,O-n— наблюдение контроллера.- Статус каждой находки:
fixed <commit>,deferredилиrejected <причина>. - Таблица
## Findings(| Маркер | Суть | Статус |) ведётся в конце ledger и копируется в каждый PR. - Две находки design.md («Найденные странности»: перекрытие полей поиска, поиск по техническим id) заносятся в таблицу сразу как
O-1иO-2со статусомdeferred.
Review Focus
- Строка без строки договора (
contract_detailнет в БД,data.contract_detail— пустая секция сnull). Человек ждёт, что поиск поcontract_summary.counterpartyтакой строки ведёт себя как на экране «до»: поля перекрытыnull, строка не находится. Еслиcd_jв SQL окажетсяNULL, оператор||дастNULL, и строка пропадёт из поиска по любому полю. Тест: Task 3 (засев) +test_search_matches_screen_oracle[*-лютик]. - Названия организаций, отличающиеся регистром или пробелом по краям (
«Филиал Ёлки»и«филиал ёлки »). Человек ждёт два варианта в фильтре «ССП» (у каждого своё число), а выбор любого из них отбирает строки обеих организаций (MRTequals). Тест: Task 1 (векторы) и Task 3 (test_filters_match_screen_oracle[org-case-variant],test_facets_match_screen_oracle[*]). - Поиск или фильтр при прокрутке глубоко вниз:
totalуменьшился ниже текущегоscrollTop. Человек ждёт, что браузер сам ограничит прокрутку и покажет последние строки, а не пустоту и не запросoffset ≥ total. Тест: Task 10 (getVisiblePageприжимает к последней странице,getWindowPagesприtotal = 0пуст). - Быстрая смена условий: ответ на старый запрос приходит после ответа на новый. Человек ждёт на экране только строки, варианты и «Найдено» нового запроса. Тест: Task 11 (устаревшие
page/facets/failигнорируются). - Данные изменились между запросами страниц (страница пришла короче или длиннее ожидаемого,
totalдругой). Человек ждёт, что строки не съезжают: высота прокрутки по последнемуtotal, недостающие места — заглушки. Ошибка догрузки оставляет заглушки и повторяет запрос при возврате к этому месту. Тест: Task 10 (buildWindowRowsдополняет и обрезает) и Task 11 (failв режимеscrollне трогает состояние, страница снова вgetMissingPages).
Фаза 1 — сервер (ветка feat/236-aurora-1843-svod2-server-paging, PR в feat/220-aurora-1842-svod2-column-virtual)
Тесты-оракулы идут первыми (Task 1–3). К концу Task 3 интеграционные тесты нового режима красные. SQL и API (Task 4–7) делают их зелёными.
Task 1: JS-оракул экрана «до» и векторы
Модель исполнителя: sonnet.
Files:
- Create:
perf/analysis/svod2_search_oracle.mjs - Create:
perf/analysis/svod2_search_oracle.test.mjs - Create (порождается скриптом):
api/tests/fixtures/js_number_text_vectors.json,api/tests/fixtures/svod2_screen_vectors.json
Interfaces:
- Consumes: код «до» —
git show 399234a:web/src/pages/Svod2Page/Svod2Page.jsx(matchesSearch,:283-294; поиск:387-391),web/node_modules/material-react-table/dist/index.esm.js(equals,:663-667;useDropdownOptions,:203-218),web/node_modules/@tanstack/table-core/build/lib/utils/getFacetedRowModel.js,getFacetedUniqueValues.js. - Produces (ESM-экспорты):
matchesSearch(row, query): boolean— дословная копия строк:283-294(const matchesSearch = …), экспорт отдельной строкойexport { matchesSearch, equals };equals(row, id, filterValue): boolean— дословная копия MRTequals(:663-667, сvar _a;). Вызывается на объекте{ getValue: (id) => row.data?.header?.[id] }: этоaccessorFnколонокvalueAt(row, ['header', id]);FILTER_COLUMNS = ['smeta_type', 'smeta_direction', 'org_name'];screenRows(rows, { search = '', filters = {} } = {}): object[]. Поиск — какfilteredRows«до»:search.trim().toLowerCase(), пустой не фильтрует. Затем MRT-фильтрequalsпо каждомуfilters[id], у которого значение истинно (equals.autoRemove = (val) => !val). Порядок строк сохраняется;screenFound(rows, search): { found: number, total: number }— N и M «Найдено» «до»;screenFacets(rows, { search = '', filters = {} } = {}): { total, search_total, smeta_type: [string, number][], smeta_direction: [...], org_name: [...] }. Для колонкиcберутся строки, найденные поиском, и к ним применяются активные фильтры всех колонок, кромеc(правилоfilterableIdsвgetFacetedRowModel). Затем подсчёт по точному значению (Map, какgetFacetedUniqueValues).null/undefinedотбрасываются, сортировка(a, b) => a.localeCompare(b)— дословный фрагментuseDropdownOptions;NUMBER_VECTOR_INPUTS: string[](JSON-литералы),SYNTHETIC_ROWS: object[],SCREEN_CASES: { search: string, filters: object }[];- CLI:
node perf/analysis/svod2_search_oracle.mjs --emit-vectors <dir>пишет<dir>/js_number_text_vectors.json([{ "input": "1234.50", "js": "1234.5" }, …], гдеjs = String(JSON.parse(input))) и<dir>/svod2_screen_vectors.json({ "rows": SYNTHETIC_ROWS, "cases": [{ "search", "filters", "line_ids", "found": { "found", "total" }, "facets": <screenFacets> }] }).
NUMBER_VECTOR_INPUTS содержит как минимум: 0, -0, 1, -1, 100, 100.00, 1234.50, 12.34, 0.5, 0.30000000000000004, 0.000001, 1e-7, -1.5e-10, 123e-20, 1e15, 1e20, 1e21, 123456789012345680000, 9007199254740993, 5e-324, 1.7976931348623157e308.
SYNTHETIC_ROWS — 10–15 строк в форме { data: { line_id, header: {…}, contract_summary: {…}, contract_detail: {…} } }. В них есть:
- кириллица разного регистра,
Ё/ё; - NBSP и
U+FEFFвнутри значений; - перекрытое поле (
contract_summary.counterparty = 'ООО Ромашка'приcontract_detail.counterparty = null) и поле договора (contract_detail.counterparty = 'ООО Ромашка Плюс'); - числа
1234.5,100,0,1e-7;true;null; smeta_direction = null;- две строки с одинаковым
org_nameи одна с вариантом'филиал ёлки '.
SCREEN_CASES — не меньше 12 сочетаний: пустой поиск; 'аРЕНДА'; 'ё'; 'ромаш'; '1234.5'; '1234.50'; ' аренда '; ' '; '1'; фильтр 'кв '; фильтр по названию организации; два фильтра вместе; поиск вместе с фильтром.
- Step 1: Написать падающие тесты в
perf/analysis/svod2_search_oracle.test.mjs(node:test,node:assert/strict):
test('перекрытое поле contract_summary не ищется', () => {
const row = { data: { header: { name: 'x' }, contract_summary: { counterparty: 'ООО Ромашка' }, contract_detail: { counterparty: null } } };
assert.equal(matchesSearch(row, 'ромаш'), false);
});
test('число ищется в записи JS', () => {
const row = { data: { header: {}, contract_summary: {}, contract_detail: JSON.parse('{"ceiling_amount": 1234.50}') } };
assert.equal(matchesSearch(row, '1234.5'), true);
assert.equal(matchesSearch(row, '1234.50'), false);
});
test('screenRows: trim + lower, NBSP по краям', () => {
const rows = [{ data: { line_id: 1, header: { name: 'Аренда помещения' } } }];
assert.deepEqual(screenRows(rows, { search: ' АРЕНДА ' }).map((r) => r.data.line_id), [1]);
});
test('equals: регистр и пробелы по краям, null не проходит', () => {
const r = (v) => ({ getValue: () => v });
assert.equal(equals(r('КВ'), 'smeta_type', 'кв '), true);
assert.equal(equals(r(null), 'smeta_type', 'кв'), false);
});
test('screenFacets: свой фильтр не применяется, чужие — да', () => { /* строки: (КВ, Развитие), (АХР, Поддержка) */
const f = screenFacets(rows2, { filters: { smeta_type: 'КВ' } });
assert.deepEqual(f.smeta_type, [['АХР', 1], ['КВ', 1]]);
assert.deepEqual(f.smeta_direction, [['Развитие', 1]]);
});
test('screenFacets: одинаковые названия — один вариант, null не выдаётся', () => { /* два 'Филиал Ёлки', один null */
assert.deepEqual(screenFacets(rows3).org_name, [['Филиал Ёлки', 2]]);
});
test('screenFound считает только поиск', () => {
assert.deepEqual(screenFound(rows2, ''), { found: 2, total: 2 });
});
test('векторы чисел', () => {
const v = Object.fromEntries(emitNumberVectors().map(({ input, js }) => [input, js]));
assert.equal(v['1e21'], '1e+21'); assert.equal(v['-0'], '0'); assert.equal(v['1e-7'], '1e-7');
assert.equal(v['0.000001'], '0.000001'); assert.equal(v['100.00'], '100'); assert.equal(v['9007199254740993'], '9007199254740992');
});
(emitNumberVectors(): { input, js }[] и emitScreenVectors(): { rows, cases } — тоже экспорты модуля, CLI их сериализует.)
- Step 2: Запустить и убедиться, что тесты падают.
WAIT_LOCK, затемcd $WT && node --test perf/analysis/svod2_search_oracle.test.mjs. Ожидается: FAIL,Cannot find module. - Step 3: Реализовать модуль
perf/analysis/svod2_search_oracle.mjsпо Interfaces.matchesSearchиequalsскопировать дословно, не переформатировать. - Step 4: Проверить дословность копий.
Ожидается:cd $WT && git show 399234a:web/src/pages/Svod2Page/Svod2Page.jsx | sed -n 283,294p | python3 -c "import sys; s=sys.stdin.read(); sys.exit(0 if s in open('perf/analysis/svod2_search_oracle.mjs').read() else 1)" && echo MATCHES_OK sed -n 663,667p web/node_modules/material-react-table/dist/index.esm.js | python3 -c "import sys; s=sys.stdin.read(); sys.exit(0 if s in open('perf/analysis/svod2_search_oracle.mjs').read() else 1)" && echo EQUALS_OKMATCHES_OK,EQUALS_OK. - Step 5: Тесты зелёные.
WAIT_LOCK;node --test perf/analysis/svod2_search_oracle.test.mjs— PASS. - Step 6: Породить векторы.
cd $WT && node perf/analysis/svod2_search_oracle.mjs --emit-vectors api/tests/fixtures. Ожидается: два файла,svod2_screen_vectors.jsonсодержитcasesдлиной не меньше 12. - Step 7: Commit.
git add perf/analysis/svod2_search_oracle.mjs perf/analysis/svod2_search_oracle.test.mjs api/tests/fixtures/js_number_text_vectors.json api/tests/fixtures/svod2_screen_vectors.json git commit -m "test: свод 2 — JS-оракул поиска, фильтров и вариантов экрана «до» и векторы"
Task 2: Python-порт оракула, сверенный с векторами JS
Модель исполнителя: sonnet.
Files:
- Create:
api/tests/svod2_oracle.py(импортfrom tests.svod2_oracle import …;pythonpath = .вapi/pytest.ini) - Create:
api/tests/unit/test_svod2_oracle.py
Interfaces:
-
Consumes:
api/tests/fixtures/js_number_text_vectors.json,api/tests/fixtures/svod2_screen_vectors.json(Task 1). -
Produces:
FILTER_COLUMNS = ("smeta_type", "smeta_direction", "org_name");js_number_text(x: int | float) -> str—Number::toString(ECMAScript): цифры изrepr(float(x))(кратчайшая запись), затем правилаk ≤ n ≤ 21/0 < n ≤ 21/−6 < n ≤ 0/ экспонентаe±,-0→"0". Тот же алгоритм, что SQL-функция в design § 3;js_string(value: object) -> str—String(value ?? ''):None→"",bool→"true"/"false", число →js_number_text,strкак есть;js_trim(s: str) -> str— обрезка по набору ECMAScriptWhiteSpace+LineTerminatorиз design § 3;matches_search(row: dict, query: str) -> bool— слияние{**header, **contract_summary, **contract_detail}(отсутствующая илиNoneгруппа — пустой словарь),query in js_string(v).lower();screen_rows(rows: list[dict], search: str = "", filters: dict[str, str] | None = None) -> list[dict];screen_found(rows: list[dict], search: str) -> tuple[int, int];screen_facets(rows: list[dict], search: str = "", filters: dict[str, str] | None = None) -> dict→{"total": int, "search_total": int, "smeta_type": dict[str, int], "smeta_direction": dict[str, int], "org_name": dict[str, int]};COMPACT_PATHS: tuple[str, ...]— пути через точку из требования «Компактное представление строки» (line_id,header.source_form, …,q4.actual_spod,q1.transfer_to_q2…);compact_projection(data: dict) -> dict— ровно то, что даётjsonb_strip_nulls(jsonb_build_object(...))поCOMPACT_PATHS. Каждая группа и подгруппа (header,plan,sequestration.DFIP,q1…) присутствует всегда, возможно пустой. Листья со значениемNoneили отсутствующие опускаются,0сохраняется.
-
Step 1: Написать падающие тесты
api/tests/unit/test_svod2_oracle.py:
FIX = Path(__file__).resolve().parents[1] / "fixtures"
NUM = json.loads((FIX / "js_number_text_vectors.json").read_text())
SCREEN = json.loads((FIX / "svod2_screen_vectors.json").read_text())
def test_js_number_text_matches_node_vectors():
for v in NUM:
assert js_number_text(json.loads(v["input"])) == v["js"], v
@pytest.mark.parametrize("case", SCREEN["cases"], ids=lambda c: f"{c['search']!r}-{c['filters']}")
def test_screen_port_matches_node(case):
rows = SCREEN["rows"]
assert [r["data"]["line_id"] for r in screen_rows(rows, case["search"], case["filters"])] == case["line_ids"]
assert screen_found(rows, case["search"]) == (case["found"]["found"], case["found"]["total"])
f = screen_facets(rows, case["search"], case["filters"])
assert (f["total"], f["search_total"]) == (case["facets"]["total"], case["facets"]["search_total"])
for col in FILTER_COLUMNS:
assert f[col] == dict(case["facets"][col])
def test_compact_projection_keeps_zero_drops_null_and_unknown():
out = compact_projection({"line_id": 1, "header": {"source_form": "FORM_1", "name": None, "vsp_id": 5},
"plan": {"plan_q1": 0, "plan_q2": None}})
assert out["line_id"] == 1
assert out["header"] == {"source_form": "FORM_1"}
assert out["plan"] == {"plan_q1": 0}
assert out["sequestration"] == {"DFIP": {}}
assert "transfer_to_q1" not in out["q1"] and set(out) >= {"q1", "q2", "q3", "q4", "contract_detail"}
- Step 2:
WAIT_LOCK;UNIT_TEST tests/unit/test_svod2_oracle.py. Ожидается: FAIL,ModuleNotFoundError: tests.svod2_oracle. - Step 3: Реализовать
api/tests/svod2_oracle.pyпо Interfaces. - Step 4:
WAIT_LOCK;UNIT_TEST tests/unit/test_svod2_oracle.py— PASS (все случаи векторов). - Step 5: Commit.
git add api/tests/svod2_oracle.py api/tests/unit/test_svod2_oracle.py; сообщениеtest(api): свод 2 — Python-оракул экрана, сверенный с векторами JS.
Task 3: Засев свода 2, эталон прежнего ответа и тесты-оракулы API (красные)
Модель исполнителя: sonnet.
Files:
- Create:
api/tests/sqls/svod2_seed.sql - Modify:
api/tests/integration/conftest.py: фикстураclientполучает строкуtest_client.db_session = db_refпередyield; новые фикстурыsvod2_data,svod2_sql - Create:
api/tests/integration/test_svod2_legacy_golden.py - Create (порождается тестом):
api/tests/fixtures/svod2_legacy_golden.json - Create:
api/tests/integration/test_svod2_paging_api.py
Interfaces:
- Consumes:
tests.svod2_oracle(Task 2), фикстурыclient,admin_tokens,isp_tokens,auth_headers. - Produces:
- фикстура
svod2_data(client) -> dict→{"year": 2031, "org_a": 910001, "org_b": 910002, "org_b_twin": 910003, "org_b_case": 910004, "cd_id": <id строки contract_detail>, "ckk_only_value": "777.25"}. Скриптsvod2_seed.sqlвыполняется наclient.db_sessionчерезclient.portal.call(...)(тот же цикл событий, что у запросов). Способ выполнения многооператорного скрипта — как вrun_seed_script_async(api/tests/conftest.py). Откат — общийrollbackфикстурыclient; - фикстура
svod2_sql(client) -> Callable[[str, dict | None], list[Row]]выполняетtext(sql)на той же сессии через портал (нужна Task 4–5); - константы теста:
SVOD2_YEAR = 2031.
- фикстура
Требования к svod2_seed.sql. Явные id от 910001, год 2031 (в fixture.sql его нет). Каждая строка проходит отбор 0049: есть хотя бы одна связанная запись с данными. Что засеять:
-
организации: A
«Тестовый филиал Альфа»(910001), B«Филиал Ёлки»(910002), двойник B с тем же названием (910003) и«филиал ёлки »(910004); -
формы FORM_1 и FORM_4 (лист GO), FORM_2 (лист RF) за 2031 год;
-
строки на все значения
smeta_type, достижимые поexpense_item.sheet, и наdirectionSupport,DevelopmentиNULL; -
header.name = 'Аренда помещения', строка сЁЛКАв названии; -
contract_summary.counterparty = 'ООО Ромашка'при строкеcontract_detailсcounterparty = NULL(перекрытие); -
contract_summary.counterparty = 'ООО Лютик'без строкиcontract_detail(Review Focus 1); -
contract_detail.counterparty = 'ООО Ромашка Плюс'; -
contract_detail.ceiling_amount = 1234.50,plan_q1 = 100.00,plan_q2 = 0; -
на листе RF строка, у которой
contract_detail.expenses_q1 IS NULL, аckk.expenses_q1 = 777.25; -
v3.user_org(user_id = 2 /* isp1 */, org_unit_id = 910001). -
Step 1: Засев и фикстуры. Написать
svod2_seed.sqlи фикстуры вconftest.py. -
Step 2: Тест эталона
test_svod2_legacy_golden.py. Случаи —GOиRF×admin_all(без параметров),admin_one(org_ids=910001),isp_all(isp1, без параметров),admin_page50(offset=0&limit=50),admin_two(offset=0&limit=2). Запись режимомSVOD2_WRITE_GOLDEN=1, иначе сравнение:
@pytest.mark.parametrize("sheet", ["GO", "RF"])
@pytest.mark.parametrize("case", GOLDEN_CASES, ids=lambda c: c.name)
def test_legacy_response_is_byte_identical(client, svod2_data, admin_tokens, isp_tokens, auth_headers, sheet, case):
r = client.get(case.url(sheet, svod2_data), headers=case.headers(auth_headers, admin_tokens, isp_tokens))
assert r.status_code == 200
if case.name == "admin_all":
assert r.json()["result"], "засев не дал строк свода 2"
key = f"{sheet}-{case.name}"
if os.getenv("SVOD2_WRITE_GOLDEN") == "1":
_record(key, r.content) # {"sha256": ..., "body": r.json()}
assert hashlib.sha256(r.content).hexdigest() == _golden()[key]["sha256"]
- Step 3: Записать эталон на коде «до».
WAIT_LOCK;SVOD2_WRITE_GOLDEN=1 API_TEST tests/integration/test_svod2_legacy_golden.py. Затем без переменной:API_TEST tests/integration/test_svod2_legacy_golden.py— PASS, два раза подряд (данные детерминированы). Если sha различаются между прогонами, в ответе есть недетерминированное поле: найти его и стабилизировать засев, эталон переписать до коммита. - Step 4: Красные тесты-оракулы
test_svod2_paging_api.py. Вспомогательнаяlegacy_full(client, sheet, headers, org_ids=None) -> list[dict]— прежний ответ без параметров; это оракульная база. Тесты:
def test_legacy_mode_has_no_total(...): # ?offset=0&limit=2 → count ≤ 2, "total" not in body
@pytest.mark.parametrize("q", ["offset=-1", "limit=0", "sort_by=bad", "sort_direction=bad", "view=bad"])
def test_invalid_params_422(...): # 422
@pytest.mark.parametrize("sheet", ["GO", "RF"])
def test_pages_stitch_to_full_list(...): # view=full, limit=2, offset 0,2,4… пока страница < 2:
# [line_id по страницам] == [line_id legacy_full]; total == len(legacy_full) на каждой странице
def test_page_past_end_has_total(...): # offset=total → result == [], count == 0, total == len(full)
def test_view_full_rows_equal_legacy_rows(...): # RF view=full&offset=0&limit=50: result == legacy_full[:50] (сравнение row dict), "total" in body
SEARCHES = ["аРЕНДА", "ё", "Ё", "ромаш", "лютик", "ромашка плюс", "1234.5", "1234.50", " аренда ",
" ", "1", ",", "777.25", "<cd_id>", "я" * 5000]
@pytest.mark.parametrize("sheet", ["GO", "RF"]) @pytest.mark.parametrize("search", SEARCHES)
def test_search_matches_screen_oracle(...): # все страницы view=compact limit=3 с search → line_id == screen_rows(full, search); total == len(...)
FILTERS = {"type-case-space": {"smeta_type": "<значение из засева в нижнем регистре + ' '>"},
"org-twin": {"org_name": "Филиал Ёлки"}, "org-case-variant": {"org_name": "филиал ёлки "},
"unknown": {"smeta_type": "нет такого"}, "dir+org": {"smeta_direction": "Развитие", "org_name": "Филиал Ёлки"},
"search+type": {"search": "аренда", "smeta_type": "<значение>"}, "long": {"org_name": "я" * 5000}}
def test_filters_match_screen_oracle(...): # line_id и total == screen_rows(full, search, filters)
def test_facets_match_screen_oracle(...): # для всех FILTERS и SEARCHES[:6]: /facets → total, search_total, {value: count} == screen_facets(...)
def test_found_matches_screen_oracle(...): # (search_total, total) из /facets == screen_found(full, search)
def test_compact_is_projection_of_full(...): # одна страница view=full и view=compact: row_type/depth/sort_order равны, data == compact_projection(full.data)
def test_rf_ckk_only_value_is_searchable(...): # RF search=777.25 → строка есть (решение #16)
def test_isp_foreign_org_is_empty(...): # isp1, org_ids=910002: страница result == [], total == 0; /facets все нули и пустые списки
def test_isp_sees_only_own_org(...): # isp1 без org_ids: total == число строк org_a в legacy_full(admin)
def test_facets_unknown_sheet_404(...): # /svod/2/XX/2031/facets → 404
-
Step 5: Убедиться, что новые тесты красные, а прежние зелёные.
WAIT_LOCK;API_TEST tests/integration/test_svod2_paging_api.py tests/integration/test_svod2_legacy_golden.py tests/integration/test_summary_api_smoke.py. Ожидается:test_legacy_mode_has_no_total,test_invalid_params_422[offset/limit/sort_*], эталон и смоук — PASS;test_invalid_params_422[view=bad]— FAIL (параметр пока игнорируется, 200);- остальные новые — FAIL:
KeyError: 'total', 404 на/facetsили несовпадение с оракулом (поиск пока игнорируется).
Список упавших записать в отчёт задачи.
xfailне ставить. -
Step 6: Commit (красные тесты в промежуточном коммите допустимы):
git add api/tests/sqls/svod2_seed.sql api/tests/integration/conftest.py api/tests/integration/test_svod2_legacy_golden.py api/tests/fixtures/svod2_legacy_golden.json api/tests/integration/test_svod2_paging_api.py git commit -m "test(api): свод 2 — засев, эталон прежнего ответа и тесты-оракулы нового режима (красные)"
Task 4: Миграция 0053 — нормализаторы и компактная проекция
Модель исполнителя: sonnet.
Files:
- Create:
api/alembic/versions/0053_svod2_server_paging.py(revision = "0053",down_revision = "0049",run_sql_file_migration("0053_svod2_server_paging.sql"),downgrade—pass, как в 0049) - Create:
api/alembic/versions/sql/0053_svod2_server_paging.sql - Create:
api/tests/integration/test_svod2_sql.py
Interfaces:
-
Consumes:
svod2_sql(Task 3),js_number_text_vectors.json(Task 1),compact_projection(Task 2). -
Produces SQL:
v3.svod2_casefold(text) RETURNS text LANGUAGE sql IMMUTABLE—translateпо таблице design § 3: ASCIIA-Z, U+0410–U+042F, U+0400–U+040F, U+0490, U+00C0–U+00DE кроме U+00D7;v3.svod2_js_trim(text) RETURNS text LANGUAGE sql IMMUTABLE—btrimпо набору design § 3;v3.svod2_js_number_text(double precision) RETURNS text LANGUAGE plpgsql IMMUTABLE SET extra_float_digits = 1— алгоритм design § 3;v3.svod2_compact(jsonb) RETURNS jsonb LANGUAGE sql IMMUTABLE—jsonb_strip_nulls(jsonb_build_object(...))по путямCOMPACT_PATHS.
-
Step 1: Написать падающие тесты в
test_svod2_sql.py:
def test_js_number_text_matches_node_vectors(svod2_sql):
for v in NUM:
got = svod2_sql("SELECT v3.svod2_js_number_text((CAST(:x AS jsonb) #>> '{}')::float8) AS t", {"x": v["input"]})[0].t
assert got == v["js"], v
def test_js_number_text_ignores_session_extra_float_digits(svod2_sql):
svod2_sql("SET LOCAL extra_float_digits = 0", None)
assert svod2_sql("SELECT v3.svod2_js_number_text(0.1::float8 + 0.2::float8) AS t", None)[0].t == "0.30000000000000004"
@pytest.mark.parametrize("src", ["АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ", "ЄІЇЎЂЏҐ", "ABCxyz", "ÀÉÖÞ", "×", "аРЕНДА 1"])
def test_casefold_matches_js_lower(svod2_sql, src):
assert svod2_sql("SELECT v3.svod2_casefold(:s) AS t", {"s": src})[0].t == src.lower()
def test_casefold_independent_of_collation(svod2_sql):
assert svod2_sql("""SELECT v3.svod2_casefold('ЁЛКА' COLLATE "C") AS t""", None)[0].t == "ёлка"
def test_js_trim_ecmascript_whitespace(svod2_sql):
assert svod2_sql("SELECT v3.svod2_js_trim(:s) AS t", {"s": " x
\t"})[0].t == "x"
assert svod2_sql("SELECT v3.svod2_js_trim(:s) AS t", {"s": " x"})[0].t == " x"
def test_compact_matches_python_projection(svod2_sql, svod2_data, client, admin_tokens, auth_headers):
full = legacy_full(client, "RF", auth_headers(admin_tokens))[0]["data"]
got = svod2_sql("SELECT v3.svod2_compact(CAST(:d AS jsonb)) AS c", {"d": json.dumps(full)})[0].c
assert got == compact_projection(full)
- Step 2:
WAIT_LOCK;API_TEST tests/integration/test_svod2_sql.py. Ожидается: FAIL,function v3.svod2_js_number_text(double precision) does not existи т. п. - Step 3: Реализовать четыре функции в
0053_svod2_server_paging.sqlи файл ревизии. - Step 4:
WAIT_LOCK;API_TEST tests/integration/test_svod2_sql.py tests/integration/test_svod2_legacy_golden.py— PASS.cd $WT/api && /Users/andrey.epifancev/projects/fomichev_claude/.venvs/dfip-api/bin/alembic heads→ одна голова0053. - Step 5: Commit.
git addтрёх файлов;perf(api): свод 2 — миграция 0053: нормализаторы поиска как в JS и компактная проекция.
Task 5: Миграция 0053 — общий отбор, страницы с total_count, варианты фильтров
Модель исполнителя: sonnet.
Files:
- Modify:
api/alembic/versions/sql/0053_svod2_server_paging.sql - Modify:
api/tests/unit/test_summary2_related_records_sql.py(перенацелить) - Modify:
api/tests/integration/test_svod2_sql.py
Interfaces:
-
Consumes: функции Task 4; тела
0049_summary2_related_records.sql(v_svod2_go:6-303,v_svod2_rf:305-595). -
Produces SQL:
DROP FUNCTION IF EXISTS v3.v_svod2_go(integer, integer[], integer, integer, varchar, varchar);и то же дляv3.v_svod2_rf;v3.svod2_lines(p_sheet varchar, p_year integer, p_org_unit_ids integer[], p_search text) RETURNS TABLE(line_id integer, form_type_code varchar, form_id integer, org_unit_id integer, org_name varchar, section_code varchar, item_id varchar, smeta_type varchar, smeta_direction varchar),LANGUAGE sql STABLE, безSET search_path, все имена сv3.. Устройство:WITH eligible_lines AS (…) SELECT … FROM eligible_lines el WHERE p_search IS NULL OR EXISTS (SELECT 1 FROM jsonb_each(header_j || cs_j || cd_j) e WHERE strpos(v3.svod2_casefold(<текст e.value>), p_search) > 0);- тело
eligible_lines— отбор 0049 без изменения условий, формы поp_sheet(GO→ FORM_1/FORM_4,RF→ FORM_2), плюс столбецsmeta_direction(тот жеCASE bl.direction, что вheader). Типы столбцов взять из 0049; - тело отбора обрамить строками-маркерами
-- svod2_lines eligible: beginи-- svod2_lines eligible: end(их читает SQLite-тест); header_j,cs_j,cd_jстроятся теми же выражениями, чтоdata.header,data.contract_summary,data.contract_detailв 0049 для своего листа, включаяCOALESCE(…, e.<пустая секция>)и для RFexpenses_qN = COALESCE(cd, ckk)(решения #16 и #21);- текст значения:
string→#>> '{}',number→v3.svod2_js_number_text((v #>> '{}')::float8),boolean→true/false,null→'';
v3.v_svod2_goиv3.v_svod2_rf:- аргументы
(p_year integer, p_org_unit_ids integer[] DEFAULT NULL, p_offset integer DEFAULT NULL, p_limit integer DEFAULT NULL, p_sort_by varchar DEFAULT 'org_name', p_sort_direction varchar DEFAULT 'asc', p_search text DEFAULT NULL, p_smeta_type text DEFAULT NULL, p_smeta_direction text DEFAULT NULL, p_org_name text DEFAULT NULL, p_compact boolean DEFAULT false); - результат — 8 столбцов 0049 +
total_count bigint; - нормализация: поиск —
NULLIF(v3.svod2_casefold(v3.svod2_js_trim(p_search)), ''), фильтры —NULLIF(v3.svod2_js_trim(v3.svod2_casefold(x)), ''); numbered_linesстроится поsvod2_linesс тремя фильтрами (x IS NOT NULL AND v3.svod2_js_trim(v3.svod2_casefold(x)) = <параметр>), с тем жеORDER BY, что в 0049, плюсcount(*) OVER () AS total_count;page,booking_dataи сборкаdataкопируются из 0049 без изменений,data=CASE WHEN p_compact THEN v3.svod2_compact(<полный>) ELSE <полный> END;SET search_pathкак в 0049;
- аргументы
v3.v_svod2_facets(p_sheet varchar, p_year integer, p_org_unit_ids integer[], p_search text, p_smeta_type text, p_smeta_direction text, p_org_name text) RETURNS TABLE(kind varchar, value varchar, n bigint):kind∈total,search_total,smeta_type,smeta_direction,org_name(уtotal/search_totalvalue = NULL);- одна выборка
svod2_lines(p_sheet, p_year, p_org_unit_ids, NULL)с флагом совпадения поиска; - для каждой колонки —
GROUP BYпо точному значению при условии «поиск + фильтры двух других колонок»,NULL-значения не выдаются.
-
Step 1: Перенацелить SQLite-тест.
SQL_PATH→0053_svod2_server_paging.sql.self.queries= [тело между маркерами приp_sheet = 'GO', то же при'RF', телоeligible_linesv_svod2_go_exportиз 0049]. Подставлятьp_sheetлитералом; столбецsmeta_directionSQLite выполнит как есть.- Пять существующих тестов не меняются по смыслу. Индексы 0/1/2 по-прежнему значат GO/RF/экспорт GO.
-
Step 2: Добавить падающие тесты в
test_svod2_sql.py:
def test_single_signature_without_overloads(svod2_sql):
rows = svod2_sql("SELECT proname, pronargs FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace "
"WHERE nspname = 'v3' AND proname IN ('v_svod2_go', 'v_svod2_rf', 'v_svod2_go_export')", None)
assert sorted((r.proname, r.pronargs) for r in rows) == [("v_svod2_go", 11), ("v_svod2_go_export", 4), ("v_svod2_rf", 11)]
@pytest.mark.parametrize("fn", ["v_svod2_go", "v_svod2_rf"])
def test_page_total_count_and_order(svod2_sql, svod2_data, fn):
full = svod2_sql(f"SELECT source_line_id FROM v3.{fn}(2031)", None)
page = svod2_sql(f"SELECT source_line_id, total_count FROM v3.{fn}(2031, NULL, 0, 2, 'org_name', 'asc', NULL, NULL, NULL, NULL, false)", None)
assert [r.source_line_id for r in page] == [r.source_line_id for r in full][:2]
assert {r.total_count for r in page} == {len(full)}
def test_facets_totals(svod2_sql, svod2_data):
rows = {(r.kind, r.value): r.n for r in svod2_sql("SELECT * FROM v3.v_svod2_facets('GO', 2031, NULL, NULL, NULL, NULL, NULL)", None)}
assert rows[("total", None)] == rows[("search_total", None)] == len(svod2_sql("SELECT 1 FROM v3.v_svod2_go(2031)", None))
- Step 3:
WAIT_LOCK;UNIT_TEST tests/unit/test_summary2_related_records_sql.pyиAPI_TEST tests/integration/test_svod2_sql.py. Ожидается FAIL: маркеров нет; 6 аргументов вместо 11;v_svod2_facets does not exist. - Step 4: Реализовать SQL по Interfaces.
- Step 5:
WAIT_LOCK;UNIT_TEST tests/unit/test_summary2_related_records_sql.py— PASS.API_TEST tests/integration/test_svod2_sql.py tests/integration/test_svod2_legacy_golden.py tests/integration/test_summary_api_smoke.py— PASS: эталон байт-в-байт и прежние 6-аргументные вызовы не изменились. - Step 6: Commit.
git addтрёх файлов;perf(api): свод 2 — общий отбор svod2_lines, страницы с total_count, варианты фильтров в SQL.
Task 6: Репозиторий, сервис и схемы ответа
Модель исполнителя: sonnet.
Files:
- Modify:
api/src/domain/schemas.py(послеSheetResponse,:357) - Modify:
api/src/repository/summary_repository.py - Modify:
api/src/services/summary_service.py - Create:
api/tests/unit/test_summary2_service.py
Interfaces:
-
Consumes: SQL Task 5.
-
Produces:
class Svod2PageResponse(BaseListResponse[SheetResponse]): total: int;class Svod2FacetValue(BaseModel): value: str; count: int;class Svod2Facets(BaseModel): total: int; search_total: int; smeta_type: list[Svod2FacetValue]; smeta_direction: list[Svod2FacetValue]; org_name: list[Svod2FacetValue];SummaryRepository.get_svod2_page(self, year: int, sheet: str, org_ids: list[int] | None, offset: int | None, limit: int | None, sort_by: str, sort_direction: str, search: str | None, smeta_type: str | None, smeta_direction: str | None, org_name: str | None, compact: bool) -> tuple[list[tuple], int]:- строки
('INPUT', 3, sort_order, data), как уget_svod2_rows; total=total_countпервой строки или0;- все параметры через
CAST(:x AS …);
- строки
SummaryRepository.get_svod2_facets(self, year: int, sheet: str, org_ids: list[int] | None, search: str | None, smeta_type: str | None, smeta_direction: str | None, org_name: str | None) -> dict→ формаSvod2Facets({"total", "search_total", "smeta_type": [{"value", "count"}], …}), отсутствующие виды —0/[];SummaryService.get_svod2_page(self, year: int, sheet: str, user: AppUser, org_ids: list[int] | None = None, offset: int | None = None, limit: int | None = None, sort_by: str = "org_name", sort_direction: str = "asc", search: str | None = None, smeta_type: str | None = None, smeta_direction: str | None = None, org_name: str | None = None, view: str = "full") -> tuple[list[tuple], int];SummaryService.get_svod2_facets(self, year: int, sheet: str, user: AppUser, org_ids: list[int] | None = None, search: str | None = None, smeta_type: str | None = None, smeta_direction: str | None = None, org_name: str | None = None) -> dict;EMPTY_SVOD2_FACETS—{"total": 0, "search_total": 0, "smeta_type": [], "smeta_direction": [], "org_name": []}(функция или копия на каждый вызов).
-
Step 1: Написать падающие тесты (
AsyncMockнаsummary_repo,_allowed_org_idsподменяетсяmonkeypatch):
async def test_page_passes_params_and_compact_flag(): ... # view="compact" → repo.get_svod2_page(..., compact=True); результат ([row], 7)
async def test_page_empty_intersection_skips_sql(): ... # _allowed_org_ids → [] → ([], 0); repo.get_svod2_page.assert_not_awaited()
async def test_page_past_end_second_call_for_total(): ... # 1-й вызов offset=600 → ([], 0); 2-й offset=0, limit=1 → ([row], 42); итог ([], 42)
async def test_page_empty_first_page_no_second_call(): ... # offset=0 → ([], 0); await_count == 1
async def test_facets_empty_intersection_zeros_without_sql(): ... # == EMPTY_SVOD2_FACETS; repo не вызван
async def test_facets_passes_allowed_org_ids_and_filters(): ... # repo.get_svod2_facets(org_ids=[1], search="x", smeta_type="КВ", ...)
- Step 2:
WAIT_LOCK;UNIT_TEST tests/unit/test_summary2_service.py— FAIL (AttributeError: get_svod2_page). - Step 3: Реализовать схемы, методы репозитория и сервиса.
- Step 4:
WAIT_LOCK;UNIT_TEST tests/unit/test_summary2_service.py— PASS. - Step 5: Commit.
perf(api): свод 2 — страницы и варианты фильтров в репозитории и сервисе.
Task 7: API — новый режим GET /svod/2/{sheet}/{year} и эндпоинт /facets
Модель исполнителя: sonnet.
Files:
- Modify:
api/src/api/v1/summary.py(get_summary2,:93-146; новый обработчик) - Create:
api/tests/unit/test_summary2_mode.py
Interfaces:
-
Consumes: Task 6.
-
Produces:
is_svod2_page_mode(view: str | None, search: str | None, smeta_type: str | None, smeta_direction: str | None, org_name: str | None) -> bool— хотя бы один неNone; пустая строка — новый режим;get_summary2с новыми параметрамиview: Optional[Literal["full", "compact"]] = None,search: Optional[str] = None,smeta_type: Optional[str] = None,smeta_direction: Optional[str] = None,org_name: Optional[str] = None— без ограничений длины. Декоратор с явнымresponse_model=Svod2PageResponse | BaseListResponse[SheetResponse]. Прежний режим возвращает тот жеBaseListResponse(count=…, result=…)черезget_svod2_rows, новый —Svod2PageResponse(count=len(rows), result=…, total=total).X-DB-Time-Msи логdb_time(с новыми полями) — в обоих режимах;@router.get("/2/{sheet}/{year}/facets") async def get_summary2_facets(sheet: str, year: int, response: Response, org_ids: Optional[str] = None, search: Optional[str] = None, smeta_type: Optional[str] = None, smeta_direction: Optional[str] = None, org_name: Optional[str] = None, db=…, current_user=…) -> BaseSingleResponse[Svod2Facets]—sheet.upper(); неизвестный лист — 404 с тем же текстом, что уget_summary2;X-DB-Time-Ms.
-
Step 1: Написать падающий unit-тест:
@pytest.mark.parametrize("args,expected", [
((None, None, None, None, None), False), (("full", None, None, None, None), True),
((None, "", None, None, None), True), ((None, None, None, None, "x"), True)])
def test_is_svod2_page_mode(args, expected):
assert is_svod2_page_mode(*args) is expected
- Step 2:
WAIT_LOCK;UNIT_TEST tests/unit/test_summary2_mode.py— FAIL (ImportError). - Step 3: Реализовать обработчики.
- Step 4: Оракулы зеленеют.
WAIT_LOCK;UNIT_TEST tests/unit/test_summary2_mode.py tests/unit/test_summary2_service.py;API_TEST tests/integration/test_svod2_paging_api.py tests/integration/test_svod2_legacy_golden.py tests/integration/test_svod2_sql.py tests/integration/test_summary_api_smoke.py. Ожидается: всё PASS, в том числе каждый случайSEARCHES/FILTERSи эталон.- Если эталон расходится только сериализацией (
response_model), найти причину в обработчике. Эталон не трогать. - Если оракул расходится с SQL, починить SQL (Task 5): оракул — дословная семантика экрана.
- Если эталон расходится только сериализацией (
- Step 5: Commit.
git add api/src/api/v1/summary.py api/tests/unit/test_summary2_mode.py;perf(api): свод 2 — новый режим страниц с total и эндпоинт вариантов фильтров.
Task 8: Завершение фазы 1 — полный прогон, передача контроллеру, финальное ревью, PR 1
Исполняет: контроллер SDD; финальный ревьюер — opus; правки по ревью — sonnet.
-
Step 1: Полный набор на PG16.
WAIT_LOCK;API_TEST tests. Ожидается ровно 3 известных падения (Global Constraints), остальное PASS. -
Step 2: Передать главному контроллеру (не блокирует фазу 2). Записать в ledger раздел
## Передано контроллеру:- «PG15: прогнать полный
pytestветкиfeat/236-aurora-1843-svod2-server-paging@<sha>наdfip-pg15:55434» — сюда входятtest_svod2_sql.py(нормализаторы при локали контура) и миграция0053; - «Стенд, байт-в-байт (#17)»:
GET /api/v1/svod/2/{GO,RF}/{year}без параметров (admin и dfip, все и одна организация,offset=0&limit=50) до и после миграции, sha256 распакованных тел; Excel RF — значения ячеек черезopenpyxl.
Ответ контроллера приходит в ledger или комментарием к PR. Красный PG15 или стенд даёт находку
O-n, её правка идёт в ветку фазы 1 с каскадным rebase фаз 2–3. - «PG15: прогнать полный
-
Step 3: Финальное ревью ветки — opus.
- Ревьюер получает diff
feat/220-aurora-1842-svod2-column-virtual...feat/236-aurora-1843-svod2-server-paging, пути спеки и правило замка. - Фокус: требования
summary2-api, Review Focus 1–2, совместимость с PG15, дословность оракула, отсутствие перегрузок. - Находки —
C-n/I-n/M-n.
- Ревьюер получает diff
-
Step 4: Правки по ревью (sonnet). Каждая находка закрывается статусом
fixed <commit>,deferredилиrejected <причина>. После правок —WAIT_LOCKиAPI_TEST tests, ожидаются те же 3 падения. -
Step 5: Документы. Дописать в
design.mdрешения, принятые при исполнении (с № 35), и обновить таблицу## Findingsв ledger. Commitdocs(openspec): aurora-1843 — решения фазы 1. -
Step 6: Push и PR 1.
git push -u origin feat/236-aurora-1843-svod2-server-paging, затем:tea pulls create --base feat/220-aurora-1842-svod2-column-virtual --head feat/236-aurora-1843-svod2-server-paging \ --title "AURORA-1843: свод 2 — серверные страницы, поиск, фильтры и варианты (1/3, сервер)" --description "$(cat <файл описания>)"Описание (по-русски):
Refs #236(часть 1/3;Closes #236— в PR 3), Jira AURORA-1843;- что сделано;
- тесты: оракулы, эталон байт-в-байт, полный
pytestна PG16 и 3 известных падения; - «PG15 — проверит контроллер»;
- чек-лист ручной проверки: старый фронт работает, прежний ответ байт-в-байт, Excel RF;
- строка «Замер до/после — добавит контроллер после прогона на стенде»;
- «Rulings I made» (
R-n); - таблица находок с маркерами и статусами;
- последняя строка
🤖 Generated with [Claude Code](https://claude.com/claude-code).
Цифр замеров в PR нет. Не мержить.
-
Step 7: Без остановки — фаза 2.
Фаза 2 — фронт (ветка feat/236-aurora-1843-svod2-server-paging-web, PR в feat/236-aurora-1843-svod2-server-paging)
Начало фазы: cd $WT && git switch -c feat/236-aurora-1843-svod2-server-paging-web от вершины фазы 1, затем git push -u origin feat/236-aurora-1843-svod2-server-paging-web.
Task 9: Проверка окна отступами виртуализатора (первая задача фазы, design #6)
Модель исполнителя: sonnet.
Files:
- Create:
web/src/pages/Svod2Page/svod2Virtualizer.test.js
Interfaces:
-
Consumes:
@tanstack/virtual-core3.11.2 (web/node_modules/@tanstack/virtual-core),material-react-table3.2.1 (dist/index.esm.js). -
Produces: вывод в отчёте задачи и ledger: «отступы работают» или «запасной вариант #6».
-
Step 1: Написать тест (
node:test):
import { Virtualizer } from '@tanstack/virtual-core';
test('paddingStart/paddingEnd ставят глобальную строку i на i*rowH, общий размер = total*rowH', () => {
const rowH = 37, total = 5563, windowStart = 300, windowEnd = 1200;
const v = new Virtualizer({ count: windowEnd - windowStart, getScrollElement: () => null, estimateSize: () => rowH,
paddingStart: windowStart * rowH, paddingEnd: (total - windowEnd) * rowH,
scrollToFn: () => {}, observeElementRect: () => {}, observeElementOffset: () => {} });
const m = v.getMeasurements();
assert.equal(m[0].start, windowStart * rowH);
assert.equal(m[m.length - 1].end, windowEnd * rowH);
assert.equal(v.getTotalSize(), total * rowH);
});
- Step 2:
WAIT_LOCK;cd $WT/web && node --test src/pages/Svod2Page/svod2Virtualizer.test.js. Ожидается PASS. Если FAIL — тест показал, что предпосылка неверна: это результат проверки, а не баг теста. - Step 3: Проверить MRT по коду и записать номера строк в отчёт:
- высота
tbody—rowVirtualizer.getTotalSize()(около:2481); - строки сдвигаются на
translateY(virtualRow.start)(около:2409); - в
useMRT_RowVirtualizerпользовательскиеrowVirtualizerOptionsразворачиваются после значений по умолчанию, в том числеmeasureElement.
- высота
- Step 4: Решение.
- Всё подтвердилось → продолжать по Task 10–12 как написано.
- Иначе остановиться и вернуть отчёт контроллеру. Контроллер выбирает запасной вариант #6 (массив длины
totalс общей заглушкой), записывает его вdesign.mdи правит Interfaces Task 10–12 до их запуска.
- Step 5: Commit.
test(web): свод 2 — окно строк через отступы виртуализатора.
Task 10: svod2Paging.js — окно, срез с заглушками, LRU, параметры, варианты
Модель исполнителя: sonnet.
Files:
- Create:
web/src/pages/Svod2Page/svod2Paging.js(чистый модуль: без React, JSX и axios) - Create:
web/src/pages/Svod2Page/svod2Paging.test.js
Interfaces:
-
Produces:
PAGE_SIZE = 300,WINDOW_RADIUS = 1,PAGE_CACHE_LIMIT = 5,FILTER_COLUMNS = ['smeta_type', 'smeta_direction', 'org_name'],EMPTY_FACET_MAP(замороженный пустойMap);getPageCount(total: number): number;getVisiblePage({ scrollTop, rowHeight, total }): number—floor(floor(scrollTop / rowHeight) / PAGE_SIZE), прижатый к[0, max(pageCount − 1, 0)];getWindowPages(visiblePage: number, total: number): number[]—visiblePage ± WINDOW_RADIUSвнутри[0, pageCount), по возрастанию; приtotal = 0—[];getWindowRange(pages: number[], total: number): { windowStart: number, windowEnd: number }([]→{0, 0});buildWindowRows(pages: number[], pageCache: Map<number, object[]>, total: number): object[]. Для каждой страницы окна ровноmin(total, (p+1)·PAGE_SIZE) − p·PAGE_SIZEэлементов: строки из кэша, недостающие места —{ __placeholder: true, index }(глобальный индекс), лишние строки отбрасываются;getVirtualizerPadding({ windowStart, windowEnd, total, rowHeight }): { paddingStart: number, paddingEnd: number };touchPage(cache: Map, pageIndex: number, rows: object[], keepPages: number[] = []): Map— новыйMap, страница становится самой свежей; вытесняются самые старые не изkeepPages, покаsize > PAGE_CACHE_LIMIT;getMissingPages(pages: number[], cache: Map, inflight: Set<number>): number[];buildSvod2QueryParams({ orgIds = [], search = '', columnFilters = [] }): Record<string, string>:org_ids— через запятую, если массив не пуст;searchкак введён, если!== '';- для
{ id, value }изcolumnFiltersсidизFILTER_COLUMNSи значением не''/null/undefined—[id]: String(value);
buildSvod2PageParams({ orgIds, search, columnFilters, offset, limit = PAGE_SIZE })—{ view: 'compact', offset, limit, ...buildSvod2QueryParams(...) };facetsToMaps(result): Map<string, Map<string, number>>— новые объекты на каждый вызов;shouldShowFound({ hasCurrentData, loading, facets }): boolean—hasCurrentData && !loading && !!facets && facets.search_total !== facets.total;isPlaceholderRow(row): boolean;getSvod2RowId(row): string—ph-${index}у заглушки, иначе прежний id${source_form}-${source_id}-${line_id}(Svod2Page.jsx:397).
-
Step 1: Написать падающие тесты:
test('окно в начале — страницы 0 и 1 (решение #15)', () => assert.deepEqual(getWindowPages(0, 5563), [0, 1]));
test('окно в середине и в конце', () => { assert.deepEqual(getWindowPages(5, 5563), [4, 5, 6]); assert.deepEqual(getWindowPages(18, 5563), [17, 18]); });
test('total = 0 — пустое окно и пустой срез', () => { assert.deepEqual(getWindowPages(0, 0), []); assert.deepEqual(buildWindowRows([], new Map(), 0), []); });
test('видимая страница прижимается к последней при уменьшении total', () =>
assert.equal(getVisiblePage({ scrollTop: 200000 * 37, rowHeight: 37, total: 700 }), 2));
test('срез: неполная последняя страница и заглушки', () => {
const rows = buildWindowRows([17, 18], new Map([[18, Array.from({ length: 163 }, (_, i) => ({ id: i }))]]), 5563);
assert.equal(rows.length, 300 + 163);
assert.deepEqual(rows[0], { __placeholder: true, index: 5100 });
assert.equal(rows[300].id, 0);
});
test('страница короче ожидаемой дополняется заглушками, длиннее — обрезается', () => { /* total изменился между запросами */ });
test('сумма отступов и строк окна = total * rowH', () => {
const { windowStart, windowEnd } = getWindowRange([4, 5, 6], 5563);
const { paddingStart, paddingEnd } = getVirtualizerPadding({ windowStart, windowEnd, total: 5563, rowHeight: 37 });
assert.equal(paddingStart + (windowEnd - windowStart) * 37 + paddingEnd, 5563 * 37);
});
test('LRU: не больше 5 страниц, страницы окна не вытесняются', () => { /* 7 вставок, keepPages = [0, 1] */ });
test('getMissingPages исключает загруженные и летящие', () => assert.deepEqual(getMissingPages([4, 5, 6], new Map([[5, []]]), new Set([6])), [4]));
test('параметры страницы: compact, пустые не передаются, search как введён', () => {
assert.deepEqual(buildSvod2PageParams({ orgIds: [], search: '', columnFilters: [], offset: 300 }), { view: 'compact', offset: 300, limit: 300 });
assert.deepEqual(buildSvod2QueryParams({ orgIds: [3, 5], search: ' аренда ', columnFilters: [{ id: 'org_name', value: 'Филиал Ёлки' }, { id: 'name', value: 'x' }] }),
{ org_ids: '3,5', search: ' аренда ', org_name: 'Филиал Ёлки' });
});
test('facetsToMaps', () => assert.equal(facetsToMaps({ smeta_type: [{ value: 'КВ', count: 3 }], smeta_direction: [], org_name: [] }).get('smeta_type').get('КВ'), 3));
test('«Найдено» — только поиск', () => {
assert.equal(shouldShowFound({ hasCurrentData: true, loading: false, facets: { total: 5563, search_total: 300 } }), true);
assert.equal(shouldShowFound({ hasCurrentData: true, loading: false, facets: { total: 5563, search_total: 5563 } }), false);
assert.equal(shouldShowFound({ hasCurrentData: true, loading: true, facets: { total: 5563, search_total: 300 } }), false);
});
test('getSvod2RowId', () => assert.equal(getSvod2RowId({ __placeholder: true, index: 7 }), 'ph-7'));
- Step 2:
WAIT_LOCK;cd $WT/web && node --test src/pages/Svod2Page/svod2Paging.test.js— FAIL (модуля нет). - Step 3: Реализовать модуль по Interfaces.
- Step 4:
WAIT_LOCK; тесты PASS;npx biome check src/pages/Svod2Page/svod2Paging.js src/pages/Svod2Page/svod2Paging.test.js— без ошибок. - Step 5: Commit.
perf(web): свод 2 — чистая логика окна страниц, параметров и вариантов.
Task 11: Редьюсер состояния вкладки (generation, устаревшие ответы, ошибки)
Модель исполнителя: sonnet.
Files:
- Modify:
web/src/pages/Svod2Page/svod2Paging.js - Modify:
web/src/pages/Svod2Page/svod2Paging.test.js
Interfaces:
-
Consumes:
touchPage(Task 10). -
Produces:
createSheetPagingState(): { generation: 0, dataGeneration: 0, pendingQueryKey: '', loadedQueryKey: '', total: 0, facets: null, pages: Map, loading: false, error: '' };svod2PagingReducer(state, action). Действия с устаревшимgeneration(≠state.generation) возвращают тот же объектstate:{ type: 'begin', generation, mode: 'load' | 'refine', queryKey }—generationиpendingQueryKeyобновляются,error = ''. Приload—loading = true. Приrefineстроки,totalиfacetsостаются (решение #20);{ type: 'page', generation, pageIndex, rows, total, keepPages }— первый ответ поколения (dataGeneration !== generation) заменяетpagesнаnew Map([[pageIndex, rows]])и ставитdataGeneration = generation,loadedQueryKey = pendingQueryKey. Следующие ответы идут черезtouchPage.total = action.total;{ type: 'facets', generation, facets }—facets = action.facets;{ type: 'fail', generation, mode: 'load' | 'refine' | 'scroll', message }:load/refine→error = message,pages = new Map(),total = 0(какrows: []при ошибке «до»);scroll→ тот же объектstate; уведомление показывает хук;
{ type: 'settled', generation }→loading = false.
-
Step 1: Написать падающие тесты:
test('устаревшие page/facets/fail игнорируются', () => {
let s = svod2PagingReducer(createSheetPagingState(), { type: 'begin', generation: 1, mode: 'load', queryKey: 'k' });
s = svod2PagingReducer(s, { type: 'begin', generation: 2, mode: 'refine', queryKey: 'k' });
for (const a of [{ type: 'page', generation: 1, pageIndex: 0, rows: [], total: 9 }, { type: 'facets', generation: 1, facets: {} },
{ type: 'fail', generation: 1, mode: 'load', message: 'x' }]) assert.equal(svod2PagingReducer(s, a), s);
});
test('refine оставляет прежние строки до первого ответа нового поколения', () => { /* pages поколения 1 видны после begin(2, refine); page(2) заменяет их */ });
test('первый ответ поколения ставит loadedQueryKey', () => { /* begin(1, load, 'k') → page(1) → loadedQueryKey === 'k' */ });
test('ошибка догрузки (scroll) не меняет состояние, страница снова среди недостающих', () => {
const s = /* состояние с pages {0}, окно [0, 1] */;
assert.equal(svod2PagingReducer(s, { type: 'fail', generation: s.generation, mode: 'scroll', message: 'x' }), s);
assert.deepEqual(getMissingPages([0, 1], s.pages, new Set()), [1]);
});
test('ошибка load очищает строки и ставит error', () => { /* error === 'x', pages.size === 0 */ });
test('последний total поколения побеждает', () => { /* page(0, total 5563) → page(1, total 5560) → total === 5560 */ });
- Step 2:
WAIT_LOCK;node --test src/pages/Svod2Page/svod2Paging.test.js— новые тесты FAIL. - Step 3: Реализовать
createSheetPagingStateиsvod2PagingReducer. - Step 4:
WAIT_LOCK; все тесты файла PASS; biome по двум файлам чист. - Step 5: Commit.
perf(web): свод 2 — состояние вкладки с поколениями запросов.
Task 12: Интеграция в Svod2Page — API, хук, таблица, заглушки
Модель исполнителя: sonnet.
Files:
- Modify:
web/src/api/summary.js(getне трогать) - Create:
web/src/pages/Svod2Page/useSvod2Pages.js - Modify:
web/src/pages/Svod2Page/Svod2Page.jsx - Modify:
web/src/pages/Svod2Page/svod2Styles.js(правило заглушки вSVOD2_TABLE_CONTAINER_SX) - Modify:
web/src/pages/Svod2Page/svod2Styles.test.js
Interfaces:
- Consumes: Task 10–11; API фазы 1 (
GET /svod/2/{sheet}/{year}?view=compact&offset&limit&…,GET /svod/2/{sheet}/{year}/facets). - Produces:
SummaryApi.getSvod2Page(sheet, year, params, { signal } = {})→api.get(\/svod/2/${sheet}/${year}`, { params, signal }).then((r) => r.data)`;SummaryApi.getSvod2Facets(sheet, year, params, { signal } = {})— то же для/facets;useSvod2Pages({ sheet, year, orgIds, queryKey, search, columnFilters, rowHeight, getScrollTop })→{ state, windowRows, padding: { paddingStart, paddingEnd }, facetsMap, load(mode: 'load' | 'refresh'), onScroll() }:- состояние по вкладкам:
{ GO, RF }→svod2PagingReducer; - на вкладку — одно поколение и
AbortControllerна запросы поколения. Новое поколение отменяет запросы прежнего; отмена axios (axios.isCancel) не считается ошибкой; load→begin(load), затемfacetsиgetMissingPages(окно от getScrollTop()),settledпо завершении всех;- смена
[search, columnFilters]приhasCurrentData→begin(refine)с теми же запросами; onScroll— черезrequestAnimationFrame: пересчёт видимой страницы и запрос недостающих страниц текущего поколения;- ошибка страницы в режиме
scroll→toast.error('Не удалось загрузить строки свода. Прокрутите ещё раз, чтобы повторить.')изreact-toastify; inflightочищается при любом исходе.
- состояние по вкладкам:
Изменения Svod2Page.jsx:
-
Убрать
getSummary,invalidateSummary,matchesSearch,filteredRows,tableStateBySheet,latestSummaryRequestRef.useSvod()остаётся только радиgetSheetFilters/updateSheetFilters. -
hasCurrentData = state.loadedQueryKey === currentQueryKey. Подсказка «Нажмите «Загрузить»…»,Alertс «Повторить» (вызываетload('refresh')) и кнопки «Загрузить»/«Обновить» остаются прежними, с теми же подписями и условиямиdisabled. -
Таблица:
data: hasCurrentData ? windowRows : [],getRowId: getSvod2RowId;manualFiltering: true,enableFacetedValues: true;getFacetedUniqueValues: (_table, columnId) => () => facetsMapRef.current.get(columnId) ?? EMPTY_FACET_MAP;filterSelectOptionsне задавать;rowVirtualizerOptions: { ...ROW_VIRTUALIZER_OPTIONS, measureElement: () => ROW_HEIGHT, ...padding }; самROW_VIRTUALIZER_OPTIONSне менять;muiTableContainerProps: { sx: SVOD2_TABLE_CONTAINER_SX, onScroll },getScrollTop— изtable.refs.tableContainerRef.current?.scrollTop ?? 0;muiTableBodyRowProps: ({ row }) => isPlaceholderRow(row.original) ? { className: 'svod2-placeholder', 'data-svod2-placeholder': '' } : {}.
-
«Найдено» —
shouldShowFound(...), текст прежний:Найдено ${facets.search_total.toLocaleString('ru-RU')} из ${facets.total.toLocaleString('ru-RU')} строк. -
updateCurrentSheetFilters(сброс колоночных фильтров при смене поиска, года, организаций) не менять. Положение прокрутки программно не менять. -
Step 1: Написать падающий тест в
svod2Styles.test.js:
test('заглушка свода 2 — прозрачный текст в строке-заглушке', () => {
const rule = SVOD2_TABLE_CONTAINER_SX['& tbody tr.svod2-placeholder td'];
assert.ok(rule, 'нет правила заглушки');
assert.equal(rule.color, 'transparent');
});
- Step 2:
WAIT_LOCK;cd $WT/web && node --test src/pages/Svod2Page/svod2Styles.test.js— FAIL. - Step 3: Реализовать
svod2Styles.js(правило заглушки:color: 'transparent', дочерним бейджам — прозрачные фон и рамку),summary.js,useSvod2Pages.js,Svod2Page.jsx. - Step 4: Проверки.
WAIT_LOCK, затем:node --test src/pages/Svod2Page/*.test.js— PASS;npx biome check src/pages/Svod2Page src/api/summary.js— без ошибок;npm run build— успешно;git grep -n "getSummary\|invalidateSummary\|matchesSearch" -- web/src/pages/Svod2Page— пусто.
- Step 5: Commit. Пять файлов;
perf(web): свод 2 — строки страницами с сервера, поиск, фильтры и варианты через API.
Task 13: Завершение фазы 2 — ручная проверка контроллера, финальное ревью, PR 2
Исполняет: контроллер SDD; финальный ревьюер — opus; правки — sonnet.
-
Step 1: Полные проверки фронта.
WAIT_LOCK, затемcd $WT/web && node --test src/components/RealtimeTable/utils/*.test.js src/pages/SvodPage/*.test.js src/pages/Svod2Page/*.test.js,npx biome check src/pages/Svod2Page src/api/summary.js,npm run build— всё зелёное. -
Step 2: Передать главному контроллеру ручную проверку на стенде (#17, не блокирует фазу 3). В ledger, раздел
## Передано контроллеру: «Фаза 2 @<sha>: сравнение со сборкой "до" на тех же данных». Чек-лист — design, «Тестирование», фаза 2, руками:- GO и RF, admin и dfip, «Все» и выбор организаций;
- поиск, три фильтра и их сочетания, варианты с числами, «Найдено», сброс фильтров;
- вкладки и положение прокрутки;
- ползунок в середину и в конец;
- быстрый ввод, затем фильтр;
- единицы, «Перейти к задачам»;
- горизонтальная прокрутка;
- ошибка догрузки;
- скриншоты.
Расхождения возвращаются находками
O-n. -
Step 3: Финальное ревью ветки — opus.
- Diff
feat/236-aurora-1843-svod2-server-paging...feat/236-aurora-1843-svod2-server-paging-web. - Фокус: требования
summary2-screen, Review Focus 3–5, неизменность поведения (подписи, условия показа, сброс фильтров), утечкиAbortController/requestAnimationFrame, отсутствие правокROW_VIRTUALIZER_OPTIONS,SvodContextиSvodFilters. - Нумерация
C/I/Mпродолжает фазу 1.
- Diff
-
Step 4: Правки по ревью (sonnet), повтор Step 1.
-
Step 5: Документы. Решения — в
design.md, таблица## Findingsв ledger. Commitdocs(openspec): aurora-1843 — решения фазы 2. -
Step 6: Push и PR 2.
git push origin feat/236-aurora-1843-svod2-server-paging-web tea pulls create --base feat/236-aurora-1843-svod2-server-paging --head feat/236-aurora-1843-svod2-server-paging-web \ --title "AURORA-1843: свод 2 — догрузка строк страницами, поиск и фильтры через сервер (2/3, фронт)" --description "$(cat <файл>)"Описание — по шаблону Task 8 Step 6:
Refs #236(часть 2/3), чек-лист ручной проверки из Step 2, «Замер до/после — добавит контроллер после прогона на стенде», «Rulings I made», таблица находок. Цифр нет. Не мержить. -
Step 7: Без остановки — фаза 3.
Фаза 3 — стенд замеров (ветка feat/236-aurora-1843-svod2-server-paging-perf, PR в feat/236-aurora-1843-svod2-server-paging-web)
Начало фазы: cd $WT && git switch -c feat/236-aurora-1843-svod2-server-paging-perf от вершины фазы 2, затем git push -u origin feat/236-aurora-1843-svod2-server-paging-perf. Исполнители стенд не запускают (Global Constraints).
Task 14: Сверка на данных стенда — режим --check JS-оракула и svod2_paging_check.py
Модель исполнителя: sonnet.
Files:
- Modify:
perf/analysis/svod2_search_oracle.mjs - Modify:
perf/analysis/svod2_search_oracle.test.mjs - Create:
perf/analysis/svod2_paging_check.py - Create:
perf/tests/test_svod2_paging_check.py
Interfaces:
-
Consumes: экспорты Task 1;
compact_projectionизapi/tests/svod2_oracle.py— подключается черезimportlib.util.spec_from_file_locationпо пути от корня репозитория; формат манифеста стенда, как вperf/analysis/api_split.py. -
Produces:
-
deriveCases(fullRows): { search: string, filters: object }[]. Запросы из спеки (аренда,АРЕНДА,1,,) плюс выведенные из данных:- подстрока названия организации;
- подстрока контрагента
contract_detail; - первое дробное число из
contract_detail/headerв записи JS; - первое
smeta_typeв нижнем регистре с пробелом в конце; - первое
org_name; - их пары;
-
compareCase(expected: { line_ids, total, found, facets }, actual: { line_ids, total, found, facets }): string[]— пустой массив, если совпало; -
runCheck({ api, username, password, year, sheets = ['GO', 'RF'], pageSize = 300 }): Promise<{ ok: boolean, report: string[] }>. Логин, прежний полный ответ, затем по каждому случаю все страницыview=compactи/facets, сравнение сscreenRows/screenFound/screenFacets; -
CLI
node perf/analysis/svod2_search_oracle.mjs --check --api <url> --username <u> --password <p> --year <y>— код выхода 1 при расхождении; -
svod2_paging_check.py --api <url> --scale <N>(пользователь dfip и год — изperf/results/manifest-s<N>.json). Чистые функции:stitch_pages(fetch_page: Callable[[int, int], dict], limit: int) -> tuple[list[dict], set[int]]— строки и множествоtotal;compare_stitched(full_rows: list[dict], stitched: list[dict]) -> list[str];check_compact(full_rows: list[dict], compact_rows: list[dict]) -> list[str].
Проверки: склейка
view=full limit=300равна прежнему ответу (dataцеликом),totalодин на всех страницах, страница за концом пуста, compact — проекция full. Код выхода 1 при расхождении.
-
-
Step 1: Написать падающие тесты.
- Node, в
svod2_search_oracle.test.mjs:deriveCasesнаSYNTHETIC_ROWSдаёт не меньше 8 случаев, и в них есть дробное число;compareCaseнаходит различие в порядкеline_idsи в числе варианта;runCheckна локальномhttp.createServer(заглушка API:/auth/login, прежний ответ изSYNTHETIC_ROWS, страницы и/facets, посчитанные теми жеscreen*) возвращаетok: true. Если заглушка портит одну страницу, возвращаетсяok: false.
- pytest, в
test_svod2_paging_check.py:stitch_pagesна фейковомfetch_pageиз 7 строк приlimit=3→ 7 строк,{7};compare_stitchedловит пропуск и повтор;check_compactловит лишний ключ и потерянный0.
- Node, в
-
Step 2:
WAIT_LOCK;cd $WT && node --test perf/analysis/svod2_search_oracle.test.mjs;cd $WT/perf && /Users/andrey.epifancev/projects/fomichev_claude/.venvs/dfip-api/bin/pytest tests/test_svod2_paging_check.py— новые тесты FAIL. -
Step 3: Реализовать.
-
Step 4: те же команды — PASS. Дословность из Task 1 Step 4 по-прежнему
MATCHES_OK/EQUALS_OK. -
Step 5: Commit.
perf: свод 2 — сверка страниц, поиска и вариантов с экраном «до» на данных стенда.
Task 15: scroll.spec.ts для свода 2 — заглушки, время до первых строк, байты
Модель исполнителя: sonnet.
Files:
- Create:
perf/e2e/lib/svod2.ts(чистый, без импортов, стираемый TS) - Create:
perf/e2e/tests/svod2-metrics.test.ts(node --test; Playwright его не подхватывает,testMatch—*.spec.ts) - Modify:
perf/e2e/scenarios/scroll.spec.ts(только веткиt.kind === 'svod2')
Interfaces:
- Produces (
lib/svod2.ts):SVOD2_DATA_ROW_SELECTOR = 'tbody tr:not([data-svod2-placeholder])',SVOD2_PLACEHOLDER_SELECTOR = 'tbody tr[data-svod2-placeholder]';svod2RespRe(sheet: string): RegExp— ответы/api/v1/svod/2/<sheet>/и/api/v1/svod/2/<sheet>/<year>/facets;isSvod2SearchUrl(url: string): boolean— запрос свода 2 с параметромsearch=;class ByteCounter— методыadd(requestId: string, bytes: number): void,markFirstRows(): void, геттерыsheet(максимум одного ответа — прежнийjson_bytes_sheet),total(сумма),first(сумма доmarkFirstRows). Без parameter properties;placeholderShare(samples: boolean[]): number— доля кадров, где видна хотя бы одна заглушка;countPagesRequested(urls: string[]): number— число запросов страниц (offset=в query).
Изменения scroll.spec.ts (design § 7):
-
для свода 2
waitRowsсчитаетSVOD2_DATA_ROW_SELECTOR; -
first_rows_ms— от клика «Загрузить» доMIN_ROWSстрок с данными; -
метрики
json_bytes_sheet(прежний смысл),json_bytes_total,json_bytes_first,placeholder_frame_share(сэмпл вrequestAnimationFrameво время прокрутки),pages_requested; -
тест фильтра: после
fill('1')ждать ответ сisSvod2SearchUrlдо 5 с (нет запроса — сборка «до», идти дальше), затемawaitStable, метрикаfilter_result_ms; -
клики
:194-200, подписи и прочиеkindне менять. Код работает и со сборкой «до»: селектор без заглушек совпадает сtbody tr. -
Step 1: Написать падающие тесты
svod2-metrics.test.ts(import { … } from '../lib/svod2.ts'):ByteCounter: три ответа 10/30/20,markFirstRowsпосле второго →sheet = 30,total = 60,first = 40;placeholderShare([true, false, false, false]) === 0.25;isSvod2SearchUrl('…/svod/2/GO/2026?view=compact&search=1') === true, безsearch—false;svod2RespRe('GO')матчит/facetsи страницу, но не/svod/2/RF/;countPagesRequested.
-
Step 2:
WAIT_LOCK;cd $WT/perf/e2e && node --test tests/svod2-metrics.test.ts— FAIL. -
Step 3: Реализовать
lib/svod2.tsи правкиscroll.spec.ts(импорт'../lib/svod2'). -
Step 4:
WAIT_LOCK; тест PASS. Проверку типов иnpx playwright test --listвыполнит контроллер (записать в ledger## Передано контроллеру). -
Step 5: Commit.
perf: scroll.spec — свод 2 со страницами: заглушки, время до первых строк, суммарные байты.
Task 16: Сценарий памяти svod2-memory.spec.ts
Модель исполнителя: sonnet.
Files:
- Create:
perf/e2e/scenarios/svod2-memory.spec.ts - Modify:
perf/e2e/lib/svod2.ts,perf/e2e/tests/svod2-metrics.test.ts
Interfaces:
-
Consumes:
lib/cdp.ts(gc,heapUsed:45,domCounters),lib/auth.ts,lib/manifest.ts,lib/results.ts— по образцуscenarios/memory.spec.ts; клики выбора «Все ССП/РФ» и «Загрузить» как вscroll.spec.ts:194-200. -
Produces:
memoryPoints(env: Record<string, string | undefined>): string[]вlib/svod2.ts—['go', 'rf', 'final'], приPERF_SVOD2_MEM_SCROLL=1—['go', 'rf', 'rf_scrolled', 'final'];- сценарий: роль dfip, свежий контекст на каждый повтор (
PERF_REPEATS, по умолчанию 3). В каждой точкеgc→heapUsedиdomCounters, запись{ scenario: 'svod2-memory', target: 'svod2/<point>', … metrics: { heap_used_mb, nodes, documents, js_event_listeners } }.
-
Step 1: Падающий тест
memoryPointsвsvod2-metrics.test.ts(оба варианта env). -
Step 2:
WAIT_LOCK;node --test tests/svod2-metrics.test.ts— FAIL. -
Step 3: Реализовать
memoryPointsи сценарий. -
Step 4:
WAIT_LOCK; тест PASS.npx playwright test --list scenarios/svod2-memory.spec.ts— передать контроллеру. -
Step 5: Commit.
perf: сценарий памяти свода 2 (GO «все» → RF «все» → GO).
Task 17: api_split.py, explain_manual.py, scale_compare.py, README
Модель исполнителя: haiku (записи и команды заданы design § 7 и ниже).
Files:
- Modify:
perf/analysis/api_split.py(списокreqs,:45-54) - Modify:
perf/analysis/explain_manual.py(queries,:59-81, и её вызов) - Modify:
perf/analysis/scale_compare.py(:35-50) - Modify:
perf/README.md - Create:
perf/tests/test_svod2_analysis.py
Interfaces:
-
Produces:
-
в
api_split.py— прежние строки плюс новые (рольdfip,yearиз манифеста):- GO и RF
?view=full&offset=0&limit=300; - GO и RF
?view=compact&offset=0&limit=300; - GO и RF
/facets; - RF
?view=compact&offset=0&limit=300&search=1;
- GO и RF
-
explain_manual.queries(f2: int, f4: int, year: int) -> list[dict]с новыми записями (у всех"user": True, варианты[("", {}), (".jit_off", {"jit": "off"})]):svod2_go_page—SELECT * FROM v3.v_svod2_go({year}, NULL, 0, 300, 'org_name', 'asc', NULL, NULL, NULL, NULL, true);svod2_rf_page— то же дляv_svod2_rf;svod2_rf_search—v_svod2_rfс'1'вp_search;svod2_go_facets—SELECT * FROM v3.v_svod2_facets('GO', {year}, NULL, NULL, NULL, NULL, NULL);
-
scale_compare.svod2_key(path: str) -> str(путь с query):/api/v1/svod/2/GO/2026→"GET свод 2 GO";- с
limit=→"GET свод 2 GO страница"; …/facets→"GET свод 2 GO facets".
elifдля свода 2 в цикле ключей используетsvod2_key; -
README: раздел «Свод 2 после AURORA-1843»:
- команды из таблицы design «Замер до/после»;
- порядок «до/после» на одних данных;
--only/--repeatsдляperfkit.bench;- запуск
svod2_paging_check.pyиsvod2_search_oracle.mjs --checkперед замером «после».
-
-
Step 1: Падающие тесты
test_svod2_analysis.py(импорт черезsys.path.insert(0, …/analysis), как вtest_analysis.py):
def test_svod2_key():
assert scale_compare.svod2_key("/api/v1/svod/2/GO/2026") == "GET свод 2 GO"
assert scale_compare.svod2_key("/api/v1/svod/2/RF/2026?view=compact&offset=0&limit=300") == "GET свод 2 RF страница"
assert scale_compare.svod2_key("/api/v1/svod/2/GO/2026/facets?search=1") == "GET свод 2 GO facets"
def test_explain_has_svod2_queries():
names = {q["name"] for q in explain_manual.queries(1, 2, 2026)}
assert {"svod2_go_page", "svod2_rf_page", "svod2_rf_search", "svod2_go_facets"} <= names
- Step 2:
WAIT_LOCK;cd $WT/perf && /Users/andrey.epifancev/projects/fomichev_claude/.venvs/dfip-api/bin/pytest tests/test_svod2_analysis.py tests/test_analysis.py— новые тесты FAIL. - Step 3: Реализовать.
- Step 4: те же команды — PASS.
- Step 5: Commit.
perf: api_split, explain_manual и scale_compare для страниц и вариантов свода 2; README.
Task 18: Завершение фазы 3 — финальное ревью, PR 3, точка ожидания замеров
Исполняет: контроллер SDD; финальный ревьюер — opus; правки — sonnet.
-
Step 1: Проверки без стенда.
WAIT_LOCK, затем:cd $WT && node --test perf/analysis/svod2_search_oracle.test.mjs;cd $WT/perf/e2e && node --test tests/svod2-metrics.test.ts;cd $WT/perf && /Users/andrey.epifancev/projects/fomichev_claude/.venvs/dfip-api/bin/pytest tests/test_svod2_paging_check.py tests/test_svod2_analysis.py tests/test_analysis.py.
Всё должно быть PASS.
-
Step 2: Финальное ревью ветки — opus.
- Diff
feat/236-aurora-1843-svod2-server-paging-web...feat/236-aurora-1843-svod2-server-paging-perf. - Фокус: сопоставимость «до/после» (код сценариев работает на сборке «до»), смысл
json_bytes_sheetсохранён, ожидание ответа поиска, правило «стенд не трогать». - Нумерация
C/I/Mпродолжает фазу 2.
- Diff
-
Step 3: Правки по ревью (sonnet), повтор Step 1.
-
Step 4: Документы. Решения — в
design.md, таблица## Findingsв ledger. Commitdocs(openspec): aurora-1843 — решения фазы 3. -
Step 5: Push и PR 3.
git push origin feat/236-aurora-1843-svod2-server-paging-perf tea pulls create --base feat/236-aurora-1843-svod2-server-paging-web --head feat/236-aurora-1843-svod2-server-paging-perf \ --title "AURORA-1843: свод 2 — сценарии и скрипты замера страниц (3/3, стенд)" --description "$(cat <файл>)"Описание — по шаблону Task 8 Step 6, но с
Closes #236. Добавить список команд замера из README и строку «Замер до/после — добавит контроллер после прогона на стенде». Цифр нет. Не мержить. -
Step 6: Точка ожидания замеров контроллера.
- Исполнение плана на этом заканчивается. Контроллер SDD отдаёт финальный отчёт:
- ссылки на три PR;
- раздел
## Передано контроллеру: PG15, байт-в-байт и Excel на стенде, ручная проверка фазы 2,playwright --list/типы, замеры; - таблица находок;
- «Rulings I made».
- Главный контроллер:
- сам делает reset стенда, но не во время чужих замеров;
- снимает «до» во временном worktree 1842 с подменой
perf/из ветки фазы 3 и «после» на вершине фазы 3, на масштабах 1 и 2, по порядку design «Замер до/после»; - перед «после» запускает
svod2_paging_check.pyиsvod2_search_oracle.mjs --check; - дописывает цифры в PR.
- Недобор целей оформляется follow-up и в таблице находок получает статус
deferred. Красная сверка — блокер слияния, правка идёт в ветку своей фазы с каскадным rebase.
- Исполнение плана на этом заканчивается. Контроллер SDD отдаёт финальный отчёт: