Compare commits
9 Commits
fbc3601673
...
3e9979d09b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e9979d09b | ||
|
|
0f7c6c5179 | ||
|
|
9272ea2527 | ||
|
|
558ad815e0 | ||
|
|
4e5ba53811 | ||
|
|
3363291801 | ||
|
|
5dc6662767 | ||
|
|
85c544b350 | ||
|
|
a663da505a |
@ -697,3 +697,5 @@ PR создаются по готовности, merge — по команде
|
||||
Цена ошибки: дрожание высоты при первом измерении; ручная проверка контроллером на стенде; после влития AURORA-1836 источник заменить на её константу.
|
||||
44. **2026-10-29 (контроллер, R-10) — приняты как отступления (deferred): окно страниц запрашивается после ответа первой страницы (T12.1); при сужающем refine глубоко внизу уходит лишний запрос `offset ≥ total` (T12.2); после ошибки смена поиска запускает refine без ожидания «Повторить» (T12.4); на границе страниц после refine/«Обновить» на один ответ видны заглушки (T12.5).**
|
||||
Цена ошибки: лишний запрос или кратковременные заглушки; проверяет главный контроллер вручную.
|
||||
45. **2026-10-29 (контроллер, R-11) — в сценариях стенда «строка с данными» исключает строки-заглушки и строки со скелетоном (`SVOD2_DATA_ROW_SELECTOR` с `:not(:has(.MuiSkeleton-root))`), `settle` в `svod2-memory` ждёт нуля скелетонов; `first_rows_ms` снимает raf-наблюдатель в странице. Причина: в режиме `load` фронт держит `loading=true` до прихода вариантов и страниц окна, и MRT рисует скелетоны в ячейках даже при непустых данных — без этой правки «до/после» завышало бы выигрыш. Это видимое пользователю отличие (скелетоны вместо строк до конца загрузки окна); решает главный контроллер после ручной проверки.**
|
||||
Цена ошибки: метрика «до/после» нечестна, либо переделка `loading`-индикации на фронте.
|
||||
|
||||
@ -268,3 +268,65 @@ cd ..
|
||||
`web/src`» в стеке каждого сэмпла (кто в коде приложения вызвал работу библиотек) и inclusive time функций `web/src`.
|
||||
- Имена выходных файлов без «trace» (`timeline-breakdown.*`): шаблон `.gitignore` `perf/**/*trace*.json` исключает трассы.
|
||||
- py-spy (`perf/requirements.txt`) на macOS требует root - не запускается без sudo; вместо него `analysis/api_split.py`.
|
||||
|
||||
## Свод 2 после AURORA-1843
|
||||
|
||||
Замер «до/после» свода 2 (серверная постраничная загрузка, компактный ответ, варианты фильтров). Цели и критерии: `openspec/changes/aurora-1843-svod2-server-paging/design.md`, раздел «Замер до/после».
|
||||
|
||||
**Порядок на одних данных** (масштаб 1, затем масштаб 2):
|
||||
|
||||
1. `perf/stand.sh reset`, `perfkit.gen.run --scale 1`, `perfkit.gen.verify --scale 1`. Своды мерить до `grow`.
|
||||
2. Скопировать `results/manifest-s1.json` в оба worktree («до» из AURORA-1842, «после» из ветки PR 3).
|
||||
Код сценариев один (из ветки PR 3): в временном worktree «до» (вершина 1842) выполнить
|
||||
`git checkout feat/236-aurora-1843-svod2-server-paging-perf -- perf/` без коммита; иначе «до» снимется старым `scroll.spec.ts` без новых метрик.
|
||||
3. «До»: `stand.sh reset/api/web-build/web/down` и `perfkit.gen.run`/`perfkit.gen.verify` (шаг 1) для «до» запускать из worktree «до»: БД остаётся на старой миграции
|
||||
(иначе 0053 уронит 6-аргументные `v_svod2_go/rf`, и API «до» пойдёт в новые функции). Поднять `api`, `web-build`, `web`, снять замеры (команды ниже).
|
||||
4. Остановить сервисы.
|
||||
5. «После»: `stand.sh migrate` и все сервисы «после» запускать из worktree «после»: `alembic upgrade head`, затем `api`, `web-build`, `web`; перед замером выполнить сверку (ниже); снять замеры.
|
||||
6. То же на масштабе 2 (`PERF_S2_REPEATS=1`, см. «Полный прогон»).
|
||||
|
||||
Между «до» и «после» не вливать AURORA-1917 и AURORA-1836: на стенде не должно быть других изменений кода.
|
||||
|
||||
**Сверка перед замером «после»** (только чтение, на данных стенда):
|
||||
|
||||
```
|
||||
cd perf
|
||||
.venv/bin/python analysis/svod2_paging_check.py --scale 1 # склейка страниц, total, проекция compact
|
||||
node analysis/svod2_search_oracle.mjs --check --api http://localhost:8000 \
|
||||
--username <dfip из results/manifest-s1.json> --password <пароль> --year <год из манифеста> # поиск и варианты фильтров; CHECK_OK
|
||||
```
|
||||
|
||||
Код выхода 1 или `CHECK_FAILED` означает расхождение с экраном «до»: замер «после» не снимать, сначала чинить код.
|
||||
|
||||
**Новые метрики** (в записях `scroll`, цели `svod/svod2_*`; на сборке «до» часть недоступна):
|
||||
|
||||
- `first_rows_ms` - от «Загрузить» до первых 20 строк с данными (без заглушек и скелетонов), опрос каждый кадр;
|
||||
- `json_bytes_sheet` - прежний смысл: максимум одного ответа; `json_bytes_total` - сумма всех ответов; `json_bytes_first` - сумма до первых строк;
|
||||
- `pages_requested` - число запросов страниц (`offset=`); на «до» всегда 0 (экран грузит всё одним запросом) - как число страниц с «после» не сравнивать;
|
||||
- `placeholder_frame_share` - доля кадров прокрутки, где видна строка-заглушка;
|
||||
- `filter_result_ms` - от ввода в поиск до стабилизации (и ответа сервера, если он был); пишется только в записи фильтра, там же нет `json_bytes_total/first`, `pages_requested` и `placeholder_frame_share` (они включили бы исходную загрузку).
|
||||
|
||||
Сценарий `svod2-memory`: записи `scenario=svod2-memory`, `target=svod2/<point>` (`go`, `rf`, `rf_scrolled` при `PERF_SVOD2_MEM_SCROLL=1`, `final`),
|
||||
метрики `heap_used_mb`, `nodes`, `documents`, `js_event_listeners`, `rows_visible`.
|
||||
|
||||
**Команды замера** (из `perf/e2e`, затем `perfkit.aggregate`; `<before|after>` и `<N>` подставить по этапу и масштабу):
|
||||
|
||||
| что | команда | время на сторону (оценка) |
|
||||
|---|---|---|
|
||||
| скролл, время до первых строк | `PERF_RUN_ID=1843-<before\|after>-s<N> PERF_TARGETS='svod/svod2_*' PERF_ROLES=dfip PERF_TRACE=0 PERF_REPEATS=2 npx playwright test scenarios/scroll.spec.ts --grep-invert filter`; на масштабе 2 ещё `PERF_MAX_STEPS=10000` | s1: «до» ~35 мин, «после» ~15 мин; s2 ×3–4 |
|
||||
| фильтр | тот же запуск с `--grep filter` | ~5 мин |
|
||||
| память | `PERF_RUN_ID=1843-mem-<…>-s<N> PERF_ROLES=dfip PERF_REPEATS=3 [PERF_SVOD2_MEM_SCROLL=1 на s1] npx playwright test scenarios/svod2-memory.spec.ts` | s1 ~10 мин, s2 ~20–30 мин |
|
||||
| байты и время API | `.venv/bin/python analysis/api_split.py --scale <N>` | ~3–5 мин |
|
||||
| планы SQL | `.venv/bin/python analysis/explain_manual.py --scale <N> --out results/1843-explain-s<N>` | ~3 мин |
|
||||
| время API по URL (bench) | `.venv/bin/python -m perfkit.bench results/<run_id> --repeats 10 --only 'GET /api/v1/svod/2/'` | зависит от числа URL |
|
||||
|
||||
Скрипты `api_split.py` и `explain_manual.py` работают на живом стенде (порты 8000 и БД `perf`); `scale_compare.py` читает уже снятые прогоны (`--s1 <run> --s2 <run>`).
|
||||
|
||||
**Ограничение `perfkit.bench`.** Без фильтра бенч повторяет все уникальные GET прогона. Для свода 2 в одиночку:
|
||||
|
||||
- `--only REGEX` — регулярное выражение по строке `METHOD url` (например `'GET /api/v1/svod/2/'`, или `'svod/2/RF.*facets'` для вариантов фильтров);
|
||||
- `--repeats N` — число повторов после прогрева (по умолчанию 20; для быстрой проверки достаточно 3–5, для замера цифр в PR — 10–20).
|
||||
|
||||
**Сравнение масштабов и разбор.** `scale_compare.py` выводит строки свода 2 ключами: `GET свод 2 <лист>` (полный ответ), `... страница` (`limit=` в query), `... facets` (варианты фильтров); с `view=` добавляется суффикс `compact`/`full`, с `search=` - `поиск`.
|
||||
|
||||
**Обновить цифры в PR** (`feat/236-aurora-1843-svod2-server-paging-perf`): PR открываются без цифр; цифры дописываются по готовности из `report-data/<run_id>/analysis/`.
|
||||
|
||||
@ -50,6 +50,13 @@ def main(argv=None):
|
||||
("dfip", "свод 1 MAIN", f"/api/v1/svod/1/MAIN/{year}"),
|
||||
("dfip", "свод 2 GO (все организации)", f"/api/v1/svod/2/GO/{year}"),
|
||||
("dfip", "свод 2 RF (все организации)", f"/api/v1/svod/2/RF/{year}"),
|
||||
("dfip", "свод 2 GO полный, страница 300", f"/api/v1/svod/2/GO/{year}?view=full&offset=0&limit=300"),
|
||||
("dfip", "свод 2 RF полный, страница 300", f"/api/v1/svod/2/RF/{year}?view=full&offset=0&limit=300"),
|
||||
("dfip", "свод 2 GO compact, страница 300", f"/api/v1/svod/2/GO/{year}?view=compact&offset=0&limit=300"),
|
||||
("dfip", "свод 2 RF compact, страница 300", f"/api/v1/svod/2/RF/{year}?view=compact&offset=0&limit=300"),
|
||||
("dfip", "свод 2 GO варианты фильтров", f"/api/v1/svod/2/GO/{year}/facets"),
|
||||
("dfip", "свод 2 RF варианты фильтров", f"/api/v1/svod/2/RF/{year}/facets"),
|
||||
("dfip", "свод 2 RF compact, страница 300, поиск 1", f"/api/v1/svod/2/RF/{year}?view=compact&offset=0&limit=300&search=1"),
|
||||
("dfip", "сводные проектов", f"/api/v1/projects/plan-summary?year={year}"),
|
||||
]
|
||||
tokens = {}
|
||||
@ -69,12 +76,13 @@ def main(argv=None):
|
||||
dt = (time.perf_counter() - t0) * 1000
|
||||
r.raise_for_status()
|
||||
if i == 0:
|
||||
rows = len(r.json().get("result") or []) # фактическое число строк ответа (манифест - до grow)
|
||||
res_j = r.json().get("result")
|
||||
rows = len(res_j) if isinstance(res_j, list) else None # строк ответа: только для списка (facets - dict)
|
||||
continue # прогрев
|
||||
tot.append(dt)
|
||||
db.append(float(r.headers.get("x-db-time-ms", "nan")))
|
||||
size = len(body)
|
||||
row = {"role": role, "request": f"{label} ({rows} строк ответа)", "rows": rows, "path": path, "total_ms": round(statistics.median(tot), 1),
|
||||
row = {"role": role, "request": (f"{label} ({rows} строк ответа)" if rows is not None else f"{label} (—)"), "rows": rows, "path": path, "total_ms": round(statistics.median(tot), 1),
|
||||
"db_ms": round(statistics.median(db), 1), "bytes": size, "content_encoding": r.headers.get("content-encoding")}
|
||||
row["python_ms"] = round(row["total_ms"] - row["db_ms"], 1)
|
||||
row["python_share"] = round(row["python_ms"] / row["total_ms"], 2)
|
||||
|
||||
@ -56,7 +56,8 @@ def subst(sql: str, form_id: int, sheet: str) -> str:
|
||||
return sql
|
||||
|
||||
|
||||
def queries(f2: int, f4: int) -> list[dict]:
|
||||
def queries(f2: int, f4: int, year: int) -> list[dict]:
|
||||
svod2_variants = [("", {}), (".jit_off", {"jit": "off"})]
|
||||
return [
|
||||
{"name": "form2_ahr_tw", "what": "FORM_2/AHR: тело v3.v_form2_sheet_sections (запрос «WITH tw», дерево статей + агрегаты)",
|
||||
"src": "api/alembic/versions/sql/0033_form2_old_sort.sql:500-1155",
|
||||
@ -78,6 +79,22 @@ def queries(f2: int, f4: int) -> list[dict]:
|
||||
"src": "api/src/repository (sheet_repository)",
|
||||
"sql": f"SELECT row_type, depth, sort_order, data FROM v3.v_form_view({f4}, 'CAP', NULL::text[], NULL::varchar)",
|
||||
"variants": [("", {}), (".jit_off", {"jit": "off"})], "user": True},
|
||||
{"name": "svod2_go_page", "what": "свод 2 GO: страница v3.v_svod2_go (300 строк, сжатый ответ, сортировка org_name)",
|
||||
"src": "api/alembic/versions/sql/0053_svod2_server_paging.sql (v3.v_svod2_go)",
|
||||
"sql": f"SELECT * FROM v3.v_svod2_go({year}, NULL, 0, 300, 'org_name', 'asc', NULL, NULL, NULL, NULL, true)",
|
||||
"variants": svod2_variants, "user": True},
|
||||
{"name": "svod2_rf_page", "what": "свод 2 RF: страница v3.v_svod2_rf (300 строк, сжатый ответ, сортировка org_name)",
|
||||
"src": "api/alembic/versions/sql/0053_svod2_server_paging.sql (v3.v_svod2_rf)",
|
||||
"sql": f"SELECT * FROM v3.v_svod2_rf({year}, NULL, 0, 300, 'org_name', 'asc', NULL, NULL, NULL, NULL, true)",
|
||||
"variants": svod2_variants, "user": True},
|
||||
{"name": "svod2_rf_search", "what": "свод 2 RF: страница с поиском '1' (v3.v_svod2_rf, отбор svod2_lines по тексту)",
|
||||
"src": "api/alembic/versions/sql/0053_svod2_server_paging.sql (v3.v_svod2_rf, v3.svod2_lines)",
|
||||
"sql": f"SELECT * FROM v3.v_svod2_rf({year}, NULL, 0, 300, 'org_name', 'asc', '1', NULL, NULL, NULL, true)",
|
||||
"variants": svod2_variants, "user": True},
|
||||
{"name": "svod2_go_facets", "what": "свод 2 GO: варианты фильтров v3.v_svod2_facets (без поиска и фильтров)",
|
||||
"src": "api/alembic/versions/sql/0053_svod2_server_paging.sql (v3.v_svod2_facets)",
|
||||
"sql": f"SELECT * FROM v3.v_svod2_facets('GO', {year}, NULL, NULL, NULL, NULL, NULL)",
|
||||
"variants": svod2_variants, "user": True},
|
||||
]
|
||||
|
||||
|
||||
@ -122,7 +139,8 @@ async def run(dsn: str, scale: int, out: Path):
|
||||
md = ["# EXPLAIN (ANALYZE, BUFFERS) вложенных запросов (explain_manual.py)", "",
|
||||
f"Postgres {pg}, work_mem по умолчанию {wm}; масштаб {scale}; FORM_2/AHR form_id={f2}, FORM_4/CAP form_id={f4}; "
|
||||
f"app.user_id={uid} (perf_rf). Каждый запрос - в транзакции с ROLLBACK.", ""]
|
||||
for q in queries(f2, f4):
|
||||
year = next(s["year"] for s in rf if s["form_type"] == "FORM_2" and s["sheet"] == "AHR")
|
||||
for q in queries(f2, f4, year):
|
||||
sql = q["sql"]
|
||||
if sql is None:
|
||||
sql = subst(func_body(*q["func_body"]), q["form_id"], q["sheet"])
|
||||
|
||||
@ -16,6 +16,7 @@ import re
|
||||
import statistics
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
PERF = Path(__file__).resolve().parents[1]
|
||||
|
||||
@ -30,6 +31,25 @@ def med(g, k):
|
||||
return m["median"] if m else None
|
||||
|
||||
|
||||
def svod2_key(path: str) -> str:
|
||||
"""Ключ свода 2 в таблице API: полный ответ, страница (limit в query; compact/full по view=), поиск (search=) или /facets."""
|
||||
m = re.match(r"/api/v1/svod/2/(\w+)/", path)
|
||||
base = "GET свод 2 " + m.group(1)
|
||||
route, _, query = path.partition("?")
|
||||
q = parse_qs(query, keep_blank_values=True)
|
||||
if route.endswith("/facets"):
|
||||
return base + " facets"
|
||||
if "limit" in q:
|
||||
base += " страница"
|
||||
if "view" in q:
|
||||
base += " " + q["view"][0]
|
||||
elif "view" in q:
|
||||
base += " " + q["view"][0]
|
||||
if "search" in q:
|
||||
base += " поиск"
|
||||
return base
|
||||
|
||||
|
||||
def api_by_sheet(run: str, scale: int) -> dict:
|
||||
man = json.loads((PERF / "report-data" / run / f"manifest-s{scale}.json").read_text())
|
||||
ft = {s["form_id"]: s["form_type"] for s in man["sheets"]}
|
||||
@ -41,7 +61,7 @@ def api_by_sheet(run: str, scale: int) -> dict:
|
||||
if m:
|
||||
key = f"GET лист {ft.get(int(m.group(1)), '?')}/{m.group(2)}"
|
||||
elif re.match(r"/api/v1/svod/2/(\w+)/", path):
|
||||
key = "GET свод 2 " + re.match(r"/api/v1/svod/2/(\w+)/", path).group(1)
|
||||
key = svod2_key(path)
|
||||
elif re.match(r"/api/v1/svod/1/(\w+)/", path):
|
||||
key = "GET свод 1 " + re.match(r"/api/v1/svod/1/(\w+)/", path).group(1)
|
||||
elif "plan-summary" in path:
|
||||
|
||||
136
perf/analysis/svod2_paging_check.py
Normal file
136
perf/analysis/svod2_paging_check.py
Normal file
@ -0,0 +1,136 @@
|
||||
"""Сверка серверной постраничной выдачи свода 2 с прежним полным ответом на данных стенда (только чтение).
|
||||
|
||||
cd perf && python analysis/svod2_paging_check.py --scale 1 [--api http://localhost:8000]
|
||||
|
||||
Пользователь dfip и год берутся из perf/results/manifest-s<N>.json. Проверки:
|
||||
- склейка `view=full&limit=300` равна прежнему ответу (`result` целиком);
|
||||
- `total` один на всех страницах; страница за концом пуста;
|
||||
- `view=compact` - проекция `view=full` (compact_projection из api/tests/svod2_oracle.py).
|
||||
Код выхода 1 при расхождении. Поиск и варианты сверяет svod2_search_oracle.mjs --check.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
PERF = Path(__file__).resolve().parents[1]
|
||||
REPO = PERF.parent
|
||||
PAGE = 300
|
||||
|
||||
|
||||
def _load_compact_projection():
|
||||
path = REPO / "api" / "tests" / "svod2_oracle.py"
|
||||
spec = importlib.util.spec_from_file_location("svod2_oracle", path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod.compact_projection
|
||||
|
||||
|
||||
compact_projection = _load_compact_projection()
|
||||
|
||||
|
||||
def stitch_pages(fetch_page: Callable[[int, int], dict], limit: int) -> tuple[list[dict], set[int]]:
|
||||
"""Страницы подряд до короткой (или пустой): строки и множество значений `total`."""
|
||||
rows: list[dict] = []
|
||||
totals: set[int] = set()
|
||||
offset = 0
|
||||
while True:
|
||||
page = fetch_page(offset, limit)
|
||||
chunk = page.get("result") or []
|
||||
rows.extend(chunk)
|
||||
if page.get("total") is not None:
|
||||
totals.add(page["total"])
|
||||
if len(chunk) < limit:
|
||||
return rows, totals
|
||||
offset += limit
|
||||
|
||||
|
||||
def _line_id(row: dict):
|
||||
return (row.get("data") or {}).get("line_id")
|
||||
|
||||
|
||||
def compare_stitched(full_rows: list[dict], stitched: list[dict]) -> list[str]:
|
||||
problems: list[str] = []
|
||||
if len(full_rows) != len(stitched):
|
||||
problems.append(f"число строк: прежний ответ {len(full_rows)}, склейка {len(stitched)}")
|
||||
ids = [_line_id(r) for r in stitched]
|
||||
dup = sorted({i for i in ids if ids.count(i) > 1}, key=str)
|
||||
if dup:
|
||||
problems.append(f"повторы line_id в склейке: {dup[:10]}")
|
||||
missing = sorted({_line_id(r) for r in full_rows} - set(ids), key=str)
|
||||
if missing:
|
||||
problems.append(f"пропущены line_id: {missing[:10]}")
|
||||
for i, (a, b) in enumerate(zip(full_rows, stitched)):
|
||||
if a != b:
|
||||
problems.append(f"первое расхождение строки на позиции {i} (line_id {_line_id(a)} против {_line_id(b)})")
|
||||
break
|
||||
return problems
|
||||
|
||||
|
||||
def check_compact(full_rows: list[dict], compact_rows: list[dict]) -> list[str]:
|
||||
problems: list[str] = []
|
||||
if len(full_rows) != len(compact_rows):
|
||||
problems.append(f"число строк: full {len(full_rows)}, compact {len(compact_rows)}")
|
||||
for i, (f, c) in enumerate(zip(full_rows, compact_rows)):
|
||||
head_f = (f.get("row_type"), f.get("depth"), f.get("sort_order"))
|
||||
head_c = (c.get("row_type"), c.get("depth"), c.get("sort_order"))
|
||||
if head_f != head_c:
|
||||
problems.append(f"позиция {i}: row_type/depth/sort_order {head_f} против {head_c}")
|
||||
elif c.get("data") != compact_projection(f.get("data") or {}):
|
||||
problems.append(f"позиция {i} (line_id {_line_id(f)}): compact не равен проекции full")
|
||||
if len(problems) >= 20:
|
||||
problems.append("... остановлено после 20 расхождений")
|
||||
break
|
||||
return problems
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
import httpx
|
||||
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--api", default="http://localhost:8000")
|
||||
ap.add_argument("--scale", type=int, required=True)
|
||||
a = ap.parse_args(argv)
|
||||
man = json.loads((PERF / "results" / f"manifest-s{a.scale}.json").read_text())
|
||||
user = man["users"]["dfip"]
|
||||
year = man["sheets"][0]["year"]
|
||||
problems: list[str] = []
|
||||
with httpx.Client(base_url=a.api, timeout=300) as c:
|
||||
r = c.post("/api/v1/auth/login", json={"username": user["username"], "password": user["password"]})
|
||||
r.raise_for_status()
|
||||
headers = {"Authorization": f"Bearer {r.json()['access_token']}"}
|
||||
|
||||
def get(sheet: str, **params) -> dict:
|
||||
resp = c.get(f"/api/v1/svod/2/{sheet}/{year}", params=params, headers=headers)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
for sheet in ("GO", "RF"):
|
||||
full_rows = get(sheet)["result"]
|
||||
for view in ("full", "compact"):
|
||||
stitched, totals = stitch_pages(lambda o, l, v=view: get(sheet, view=v, offset=o, limit=l), PAGE)
|
||||
if view == "full":
|
||||
found = compare_stitched(full_rows, stitched)
|
||||
else:
|
||||
full_stitched, _ = stitch_pages(lambda o, l: get(sheet, view="full", offset=o, limit=l), PAGE)
|
||||
found = check_compact(full_stitched, stitched)
|
||||
if totals != {len(full_rows)}:
|
||||
found.append(f"total на страницах {sorted(totals)}, ожидалось {{{len(full_rows)}}}")
|
||||
tail = get(sheet, view=view, offset=len(full_rows), limit=PAGE)
|
||||
if tail["result"]:
|
||||
found.append(f"страница за концом не пуста: {len(tail['result'])} строк")
|
||||
problems += [f"{sheet} {view}: {p}" for p in found]
|
||||
print(f"{sheet} {view}: строк {len(stitched)}, расхождений {len(found)}")
|
||||
for p in problems:
|
||||
print(p)
|
||||
print("CHECK_OK" if not problems else f"CHECK_FAILED: {len(problems)}")
|
||||
return 1 if problems else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@ -138,6 +138,152 @@ export function emitScreenVectors() {
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Режим --check: сверка ответов API с экраном «до» на данных стенда (только чтение) ----
|
||||
|
||||
const firstDefined = (rows, pick) => {
|
||||
for (const row of rows) {
|
||||
const v = pick(row);
|
||||
if (v !== null && v !== undefined && v !== '') return v;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const middle = (text) => {
|
||||
const t = String(text).trim();
|
||||
return t.length <= 4 ? t : t.slice(1, Math.min(t.length, 7));
|
||||
};
|
||||
|
||||
export function deriveCases(fullRows) {
|
||||
const cases = [];
|
||||
const seen = new Set();
|
||||
const add = (search, filters = {}) => {
|
||||
const key = JSON.stringify([search, filters]);
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
cases.push({ search, filters });
|
||||
};
|
||||
for (const q of ['аренда', 'АРЕНДА', '1', ',']) add(q);
|
||||
|
||||
const org = firstDefined(fullRows, (r) => r.data?.header?.org_name);
|
||||
const orgSub = org === undefined ? undefined : middle(org);
|
||||
const cp = firstDefined(fullRows, (r) => r.data?.contract_detail?.counterparty);
|
||||
const cpSub = cp === undefined ? undefined : middle(cp);
|
||||
const fractional = firstDefined(fullRows, (r) => {
|
||||
const values = [...Object.values(r.data?.contract_detail ?? {}), ...Object.values(r.data?.header ?? {})];
|
||||
return values.find((v) => typeof v === 'number' && !Number.isInteger(v));
|
||||
});
|
||||
const num = fractional === undefined ? undefined : String(fractional);
|
||||
const type = firstDefined(fullRows, (r) => r.data?.header?.smeta_type);
|
||||
const typeFilter = type === undefined ? undefined : `${String(type).toLowerCase()} `;
|
||||
|
||||
if (orgSub !== undefined) add(orgSub.toLowerCase());
|
||||
if (cpSub !== undefined) add(cpSub);
|
||||
if (num !== undefined) add(num);
|
||||
if (typeFilter !== undefined) add('', { smeta_type: typeFilter });
|
||||
if (org !== undefined) add('', { org_name: org });
|
||||
// пары
|
||||
if (orgSub !== undefined && typeFilter !== undefined) add(orgSub, { smeta_type: typeFilter });
|
||||
if (cpSub !== undefined && org !== undefined) add(cpSub, { org_name: org });
|
||||
if (num !== undefined && typeFilter !== undefined) add(num, { smeta_type: typeFilter });
|
||||
if (typeFilter !== undefined && org !== undefined) add('', { smeta_type: typeFilter, org_name: org });
|
||||
if (cpSub !== undefined && typeFilter !== undefined && org !== undefined) {
|
||||
add(cpSub, { smeta_type: typeFilter, org_name: org });
|
||||
}
|
||||
return cases;
|
||||
}
|
||||
|
||||
const sortedFacets = (facets) => {
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(facets ?? {})) {
|
||||
out[k] = Array.isArray(v) ? v.map(([value, count]) => [value, count]).sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)) : v;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
// expected/actual: { line_ids, total, found, facets }, facets в форме screenFacets ([значение, число]).
|
||||
export function compareCase(expected, actual) {
|
||||
const diffs = [];
|
||||
const a = expected.line_ids ?? [];
|
||||
const b = actual.line_ids ?? [];
|
||||
if (a.length !== b.length) diffs.push(`line_ids: длина ${a.length} против ${b.length}`);
|
||||
else {
|
||||
const at = a.findIndex((id, i) => id !== b[i]);
|
||||
if (at >= 0) diffs.push(`line_ids: первое расхождение на позиции ${at}: ${a[at]} против ${b[at]}`);
|
||||
}
|
||||
if (expected.total !== actual.total) diffs.push(`total: ${expected.total} против ${actual.total}`);
|
||||
if (expected.found !== actual.found) diffs.push(`found: ${expected.found} против ${actual.found}`);
|
||||
const ef = sortedFacets(expected.facets);
|
||||
const af = sortedFacets(actual.facets);
|
||||
for (const key of new Set([...Object.keys(ef), ...Object.keys(af)])) {
|
||||
if (JSON.stringify(ef[key]) !== JSON.stringify(af[key])) {
|
||||
diffs.push(`facets.${key}: ${JSON.stringify(ef[key])} против ${JSON.stringify(af[key])}`);
|
||||
}
|
||||
}
|
||||
return diffs;
|
||||
}
|
||||
|
||||
const apiFacetsToScreen = (result) => {
|
||||
const out = { total: result.total, search_total: result.search_total };
|
||||
for (const id of FILTER_COLUMNS) out[id] = (result[id] ?? []).map(({ value, count }) => [value, count]);
|
||||
return out;
|
||||
};
|
||||
|
||||
export async function runCheck({ api, username, password, year, sheets = ['GO', 'RF'], pageSize = 300 }) {
|
||||
const report = [];
|
||||
const base = api.replace(/\/$/, '');
|
||||
const getJson = async (url, init) => {
|
||||
const res = await fetch(url, init);
|
||||
if (!res.ok) throw new Error(`${init?.method ?? 'GET'} ${url}: HTTP ${res.status}`);
|
||||
return res.json();
|
||||
};
|
||||
const login = await getJson(`${base}/api/v1/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
const headers = { authorization: `Bearer ${login.access_token}` };
|
||||
const qs = (extra) => {
|
||||
const p = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(extra)) if (v !== undefined && v !== '') p.set(k, String(v));
|
||||
return p.toString();
|
||||
};
|
||||
for (const sheet of sheets) {
|
||||
const url = `${base}/api/v1/svod/2/${sheet}/${year}`;
|
||||
const legacy = await getJson(url, { headers });
|
||||
const fullRows = legacy.result ?? [];
|
||||
for (const c of deriveCases(fullRows)) {
|
||||
const label = `${sheet} search=${JSON.stringify(c.search)} filters=${JSON.stringify(c.filters)}`;
|
||||
try {
|
||||
const params = { view: 'compact', search: c.search, ...c.filters };
|
||||
const ids = [];
|
||||
let total;
|
||||
for (let offset = 0; ; offset += pageSize) {
|
||||
const page = await getJson(`${url}?${qs({ ...params, offset, limit: pageSize })}`, { headers });
|
||||
const rows = page.result ?? [];
|
||||
if (offset === 0) total = page.total;
|
||||
else if (page.total !== total) report.push(`${label}: total меняется между страницами: ${total} и ${page.total}`);
|
||||
for (const r of rows) ids.push(r.data?.line_id);
|
||||
if (rows.length < pageSize || ids.length >= page.total) break;
|
||||
}
|
||||
const facets = await getJson(`${url}/facets?${qs({ search: c.search, ...c.filters })}`, { headers });
|
||||
const expectedRows = screenRows(fullRows, c);
|
||||
const expected = {
|
||||
line_ids: expectedRows.map((r) => r.data?.line_id),
|
||||
total: expectedRows.length,
|
||||
found: screenFound(fullRows, c.search).found,
|
||||
facets: screenFacets(fullRows, c),
|
||||
};
|
||||
const actualFacets = apiFacetsToScreen(facets.result ?? {});
|
||||
const actual = { line_ids: ids, total, found: actualFacets.search_total, facets: actualFacets };
|
||||
for (const d of compareCase(expected, actual)) report.push(`${label}: ${d}`);
|
||||
} catch (e) {
|
||||
report.push(`${label}: ошибка ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ok: report.length === 0, report };
|
||||
}
|
||||
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
const i = process.argv.indexOf('--emit-vectors');
|
||||
if (i > 0 && process.argv[i + 1]) {
|
||||
@ -147,3 +293,19 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
writeFileSync(path.join(dir, 'svod2_screen_vectors.json'), `${JSON.stringify(emitScreenVectors(), null, 2)}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url) && process.argv.includes('--check')) {
|
||||
const arg = (name) => {
|
||||
const i = process.argv.indexOf(`--${name}`);
|
||||
return i > 0 ? process.argv[i + 1] : undefined;
|
||||
};
|
||||
const { ok, report } = await runCheck({
|
||||
api: arg('api'),
|
||||
username: arg('username'),
|
||||
password: arg('password'),
|
||||
year: arg('year'),
|
||||
});
|
||||
for (const line of report) console.log(line);
|
||||
console.log(ok ? 'CHECK_OK' : `CHECK_FAILED: расхождений ${report.length}`);
|
||||
process.exit(ok ? 0 : 1);
|
||||
}
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import http from 'node:http';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
SYNTHETIC_ROWS,
|
||||
compareCase,
|
||||
deriveCases,
|
||||
runCheck,
|
||||
emitNumberVectors,
|
||||
emitScreenVectors,
|
||||
equals,
|
||||
@ -58,3 +63,86 @@ test('векторы экрана: не меньше 12 случаев', () => {
|
||||
assert.ok(s.cases.length >= 12);
|
||||
assert.ok(s.rows.length >= 10 && s.rows.length <= 15);
|
||||
});
|
||||
|
||||
test('deriveCases: не меньше 8 случаев и есть дробное число', () => {
|
||||
const cases = deriveCases(SYNTHETIC_ROWS);
|
||||
assert.ok(cases.length >= 8);
|
||||
for (const required of ['аренда', 'АРЕНДА', '1', ',']) {
|
||||
assert.ok(cases.some((c) => c.search === required && Object.keys(c.filters).length === 0), required);
|
||||
}
|
||||
assert.ok(cases.some((c) => /\d\.\d|\de-\d/.test(c.search)));
|
||||
});
|
||||
|
||||
const expectedFor = (rows, { search, filters }) => ({
|
||||
line_ids: screenRows(rows, { search, filters }).map((r) => r.data.line_id),
|
||||
total: screenRows(rows, { search, filters }).length,
|
||||
found: screenFound(rows, search).found,
|
||||
facets: screenFacets(rows, { search, filters }),
|
||||
});
|
||||
|
||||
test('compareCase: совпадение даёт пустой массив, различия находятся', () => {
|
||||
const base = expectedFor(SYNTHETIC_ROWS, { search: 'ё', filters: {} });
|
||||
assert.deepEqual(compareCase(base, structuredClone(base)), []);
|
||||
const swapped = structuredClone(base);
|
||||
swapped.line_ids.reverse();
|
||||
assert.ok(compareCase(base, swapped).length > 0);
|
||||
const counted = structuredClone(base);
|
||||
counted.facets.smeta_type[0][1] += 1;
|
||||
assert.ok(compareCase(base, counted).length > 0);
|
||||
});
|
||||
|
||||
const startStub = async ({ corrupt = false } = {}) => {
|
||||
const server = http.createServer((req, res) => {
|
||||
const url = new URL(req.url, 'http://x');
|
||||
const send = (body, status = 200) => {
|
||||
res.writeHead(status, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify(body));
|
||||
};
|
||||
if (url.pathname === '/api/v1/auth/login') return send({ access_token: 't' });
|
||||
const m = url.pathname.match(/^\/api\/v1\/svod\/2\/(GO|RF)\/(\d+)(\/facets)?$/);
|
||||
if (!m) return send({}, 404);
|
||||
const q = url.searchParams;
|
||||
const opts = {
|
||||
search: q.get('search') ?? '',
|
||||
filters: Object.fromEntries(['smeta_type', 'smeta_direction', 'org_name'].filter((k) => q.get(k)).map((k) => [k, q.get(k)])),
|
||||
};
|
||||
const hasNew = ['view', 'search', 'smeta_type', 'smeta_direction', 'org_name'].some((k) => q.has(k));
|
||||
if (!hasNew) return send({ result: SYNTHETIC_ROWS.map((r) => ({ row_type: 'row', depth: 0, sort_order: null, data: r.data })) });
|
||||
if (m[3]) {
|
||||
const f = screenFacets(SYNTHETIC_ROWS, opts);
|
||||
const out = { total: f.total, search_total: f.search_total };
|
||||
for (const id of ['smeta_type', 'smeta_direction', 'org_name']) out[id] = f[id].map(([value, count]) => ({ value, count }));
|
||||
return send({ result: out });
|
||||
}
|
||||
const all = screenRows(SYNTHETIC_ROWS, opts);
|
||||
const offset = Number(q.get('offset') ?? 0);
|
||||
const limit = Number(q.get('limit') ?? all.length);
|
||||
let page = all.slice(offset, offset + limit);
|
||||
if (corrupt && offset === 0) page = page.slice().reverse();
|
||||
return send({ count: page.length, total: all.length, result: page.map((r) => ({ row_type: 'row', depth: 0, sort_order: null, data: r.data })) });
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
return { server, api: `http://127.0.0.1:${server.address().port}` };
|
||||
};
|
||||
|
||||
test('runCheck на заглушке API: ok true', async () => {
|
||||
const { server, api } = await startStub();
|
||||
try {
|
||||
const r = await runCheck({ api, username: 'u', password: 'p', year: 2026, pageSize: 3 });
|
||||
assert.deepEqual(r.report, []);
|
||||
assert.equal(r.ok, true);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('runCheck: испорченная страница даёт ok false', async () => {
|
||||
const { server, api } = await startStub({ corrupt: true });
|
||||
try {
|
||||
const r = await runCheck({ api, username: 'u', password: 'p', year: 2026, pageSize: 3 });
|
||||
assert.equal(r.ok, false);
|
||||
assert.ok(r.report.length > 0);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
82
perf/e2e/lib/svod2.ts
Normal file
82
perf/e2e/lib/svod2.ts
Normal file
@ -0,0 +1,82 @@
|
||||
/** Чистые помощники замеров свода 2 (без импортов, стираемый TS: проверяются `node --test`). */
|
||||
|
||||
/** Строка данных: не заглушка окна и без скелетонов MRT (isLoading рисует .MuiSkeleton-root в ячейках даже при непустых данных). */
|
||||
export const SVOD2_DATA_ROW_SELECTOR = 'tbody tr:not([data-svod2-placeholder]):not(:has(.MuiSkeleton-root))';
|
||||
export const SVOD2_PLACEHOLDER_SELECTOR = 'tbody tr[data-svod2-placeholder]';
|
||||
|
||||
/** Ответы свода 2: страница `/svod/2/<sheet>/...` и `/svod/2/<sheet>/<year>/facets`. */
|
||||
export function svod2RespRe(sheet: string): RegExp {
|
||||
return new RegExp(`/api/v1/svod/2/${sheet}/`);
|
||||
}
|
||||
|
||||
/** Запрос свода 2 с параметром search= (серверный поиск). */
|
||||
export function isSvod2SearchUrl(url: string): boolean {
|
||||
const q = url.indexOf('?');
|
||||
if (q < 0 || !/\/api\/v1\/svod\/2\//.test(url.slice(0, q))) return false;
|
||||
return url
|
||||
.slice(q + 1)
|
||||
.split('#')[0]
|
||||
.split('&')
|
||||
.some((p) => p === 'search' || p.startsWith('search='));
|
||||
}
|
||||
|
||||
/** Число запросов страниц (offset= в query). */
|
||||
export function countPagesRequested(urls: string[]): number {
|
||||
let n = 0;
|
||||
for (const u of urls) {
|
||||
const q = u.indexOf('?');
|
||||
if (q < 0) continue;
|
||||
if (u.slice(q + 1).split('#')[0].split('&').some((p) => p === 'offset' || p.startsWith('offset='))) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Доля кадров, где видна хотя бы одна заглушка. */
|
||||
export function placeholderShare(samples: boolean[]): number {
|
||||
if (samples.length === 0) return 0;
|
||||
let k = 0;
|
||||
for (const s of samples) if (s) k++;
|
||||
return k / samples.length;
|
||||
}
|
||||
|
||||
/** Байты ответов по requestId: sheet - максимум одного ответа, total - сумма, first - сумма до markFirstRows. */
|
||||
export class ByteCounter {
|
||||
perRequest: Map<string, number> = new Map();
|
||||
totalBytes = 0;
|
||||
firstBytes = 0;
|
||||
marked = false;
|
||||
|
||||
add(requestId: string, bytes: number): void {
|
||||
this.perRequest.set(requestId, (this.perRequest.get(requestId) ?? 0) + bytes);
|
||||
this.totalBytes += bytes;
|
||||
if (!this.marked) this.firstBytes += bytes;
|
||||
}
|
||||
|
||||
markFirstRows(): void {
|
||||
this.marked = true;
|
||||
}
|
||||
|
||||
get sheet(): number {
|
||||
let m = 0;
|
||||
for (const v of this.perRequest.values()) if (v > m) m = v;
|
||||
return m;
|
||||
}
|
||||
|
||||
get total(): number {
|
||||
return this.totalBytes;
|
||||
}
|
||||
|
||||
get first(): number {
|
||||
return this.firstBytes;
|
||||
}
|
||||
}
|
||||
|
||||
/** filter_result_ms: от fill до стабилизации; ответ поиска (если был) не должен быть позже конца; таймаут ожидания (respAt=null) не учитывается. */
|
||||
export function filterResultMs(startAt: number, stableAt: number, respAt: number | null): number {
|
||||
return Math.max(stableAt, respAt ?? 0) - startAt;
|
||||
}
|
||||
|
||||
/** Точки замера памяти свода 2: GO «все» -> RF «все» -> (опц. прокрутка RF) -> снова GO. */
|
||||
export function memoryPoints(env: Record<string, string | undefined>): string[] {
|
||||
return env.PERF_SVOD2_MEM_SCROLL === '1' ? ['go', 'rf', 'rf_scrolled', 'final'] : ['go', 'rf', 'final'];
|
||||
}
|
||||
@ -29,6 +29,7 @@ import { attachNetlog } from '../lib/netlog';
|
||||
import { append, runDir } from '../lib/results';
|
||||
import { scrollThrough, type Axis } from '../lib/scroll';
|
||||
import { shouldTrace } from '../lib/trace';
|
||||
import { ByteCounter, SVOD2_DATA_ROW_SELECTOR, SVOD2_PLACEHOLDER_SELECTOR, countPagesRequested, filterResultMs as filterResultMsOf, isSvod2SearchUrl, placeholderShare, svod2RespRe } from '../lib/svod2';
|
||||
|
||||
const CONTAINER = '.MuiTableContainer-root'; // MRT: muiTableContainerProps (RealtimeTable.jsx), SummaryTable, Svod2Page
|
||||
const MIN_ROWS = 20;
|
||||
@ -82,7 +83,7 @@ function buildTargets(): Target[] {
|
||||
const svodRoles = ['admin', 'dfip'].filter(hasRole).map((role) => ({ role, axes: ['y'] as Axis[], repeats: repeats(3) }));
|
||||
const svod1 = ['MAIN', 'FORM_1', 'FORM_2', 'FORM_3', 'FORM_4'];
|
||||
svod1.forEach((sh, i) => out.push({ name: `svod/svod1_${sh}`, kind: 'svod1', url: '/svod', tabIndex: i, respRe: new RegExp(`/api/v1/svod/1/${sh}/`), rolesAxes: svodRoles }));
|
||||
['GO', 'RF'].forEach((sh, i) => out.push({ name: `svod/svod2_${sh}`, kind: 'svod2', url: '/svod/2', tabIndex: i, respRe: new RegExp(`/api/v1/svod/2/${sh}/`), rolesAxes: svodRoles }));
|
||||
['GO', 'RF'].forEach((sh, i) => out.push({ name: `svod/svod2_${sh}`, kind: 'svod2', url: '/svod/2', tabIndex: i, respRe: svod2RespRe(sh), rolesAxes: svodRoles }));
|
||||
out.push({ name: 'svod/projects_summaries', kind: 'summaries', url: '/projects/summaries', respRe: /\/api\/v1\/projects\/plan-summary/, rolesAxes: svodRoles });
|
||||
return out;
|
||||
}
|
||||
@ -95,8 +96,8 @@ function buildTargets(): Target[] {
|
||||
const SETTLE_MS = 20_000;
|
||||
class RowsError extends Error {}
|
||||
const skipLog: string[] = [];
|
||||
async function waitRows(page: Page): Promise<number> {
|
||||
const count = () => page.evaluate((sel) => document.querySelectorAll(`${sel} tbody tr`).length, CONTAINER);
|
||||
async function waitRows(page: Page, rowSel = 'tbody tr'): Promise<number> {
|
||||
const count = () => page.evaluate(([sel, rs]) => document.querySelectorAll(`${sel} ${rs}`).length, [CONTAINER, rowSel]);
|
||||
const t0 = Date.now();
|
||||
let last = -1, lastChange = t0;
|
||||
for (;;) {
|
||||
@ -175,42 +176,61 @@ async function measure(browser: import('@playwright/test').Browser, o: RunOpts)
|
||||
const page = await ctx.newPage();
|
||||
// размер ответа загрузки данных: сумма распакованных чанков Network.dataReceived
|
||||
// (response.body() для 30-47 МБ недоступен: "evicted from inspector cache")
|
||||
let jsonBytes = 0;
|
||||
const bytes = new ByteCounter();
|
||||
const pageUrls: string[] = []; // запросы свода 2 (для pages_requested)
|
||||
const net = await ctx.newCDPSession(page);
|
||||
await net.send('Network.enable');
|
||||
const reqUrl = new Map<string, string>();
|
||||
const reqBytes = new Map<string, number>();
|
||||
net.on('Network.requestWillBeSent', (e: any) => { reqUrl.set(e.requestId, e.request.url); });
|
||||
net.on('Network.requestWillBeSent', (e: any) => {
|
||||
reqUrl.set(e.requestId, e.request.url);
|
||||
if (t.kind === 'svod2' && t.respRe.test(e.request.url)) pageUrls.push(e.request.url);
|
||||
});
|
||||
net.on('Network.dataReceived', (e: any) => {
|
||||
const u = reqUrl.get(e.requestId);
|
||||
if (u && t.respRe.test(u)) {
|
||||
const v = (reqBytes.get(e.requestId) ?? 0) + e.dataLength;
|
||||
reqBytes.set(e.requestId, v);
|
||||
jsonBytes = Math.max(jsonBytes, v);
|
||||
}
|
||||
if (u && t.respRe.test(u)) bytes.add(e.requestId, e.dataLength);
|
||||
});
|
||||
await page.goto(WEB + t.url);
|
||||
if (t.tabIndex) await page.getByRole('tab').nth(t.tabIndex).click();
|
||||
const rowSel = t.kind === 'svod2' ? SVOD2_DATA_ROW_SELECTOR : 'tbody tr';
|
||||
let tLoad = 0;
|
||||
if (t.kind === 'svod2') {
|
||||
// свод 2 не грузит данные сам: нужно выбрать «Все ССП/РФ» и нажать «Загрузить» (Svod2Page.jsx)
|
||||
await page.getByLabel('ССП/РФ').click();
|
||||
await page.getByRole('option', { name: 'Все ССП/РФ' }).click();
|
||||
await page.keyboard.press('Escape');
|
||||
// first_rows_ms: raf-наблюдатель в странице (момент, когда строк данных >= MIN_ROWS); ожидание и отказ - прежний waitRows
|
||||
await page.evaluate(([sel, rs, min]) => {
|
||||
const st = { at: 0 };
|
||||
(window as any).__firstRows = st;
|
||||
const tick = () => {
|
||||
if (document.querySelectorAll(`${sel} ${rs}`).length >= (min as number)) { st.at = Date.now(); return; }
|
||||
requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
}, [CONTAINER, rowSel, MIN_ROWS]);
|
||||
tLoad = Date.now();
|
||||
await page.getByRole('button', { name: 'Загрузить' }).click();
|
||||
}
|
||||
let rows: number;
|
||||
let firstRowsMs = 0;
|
||||
try {
|
||||
rows = await waitRows(page);
|
||||
rows = await waitRows(page, rowSel);
|
||||
if (t.kind === 'svod2') {
|
||||
const at = await page.evaluate(() => (window as any).__firstRows?.at as number | undefined);
|
||||
firstRowsMs = (at || Date.now()) - tLoad;
|
||||
bytes.markFirstRows();
|
||||
}
|
||||
} catch (e) {
|
||||
if (!(e instanceof RowsError)) throw e;
|
||||
const have = await page.evaluate((sel) => document.querySelectorAll(`${sel} tbody tr`).length, CONTAINER).catch(() => -1);
|
||||
const have = await page.evaluate(([sel, rs]) => document.querySelectorAll(`${sel} ${rs}`).length, [CONTAINER, rowSel]).catch(() => -1);
|
||||
const skip_reason = `rows<${MIN_ROWS}: ${String(e)} (url ${page.url()})`;
|
||||
skipLog.push(`${targetName} ${role} cpu${cpu} #${repeat}: ${skip_reason}`);
|
||||
append({ ...rec, metrics: { rows_visible_before: have, json_bytes_sheet: jsonBytes }, artifacts, incomplete: true, skip_reason });
|
||||
append({ ...rec, metrics: { rows_visible_before: have, json_bytes_sheet: bytes.sheet }, artifacts, incomplete: true, skip_reason });
|
||||
return;
|
||||
}
|
||||
await page.waitForTimeout(500);
|
||||
await net.send('Network.disable').catch(() => {});
|
||||
// свод 2 дозагружает страницы при прокрутке: сеть слушаем до конца действия
|
||||
if (t.kind !== 'svod2') await net.send('Network.disable').catch(() => {});
|
||||
await throttle(page, cpu);
|
||||
|
||||
// trace только на повторе 0 при повторах > 1 (lib/trace.ts): при одном повторе запись traced=1 была бы единственной
|
||||
@ -221,25 +241,68 @@ async function measure(browser: import('@playwright/test').Browser, o: RunOpts)
|
||||
const sigBefore = filter ? await signature(page) : '';
|
||||
if (filter) await armStable(page);
|
||||
const pr = await startProbe(page);
|
||||
let filterResultMs: number | undefined;
|
||||
let placeholderSamples: boolean[] = [];
|
||||
if (filter) {
|
||||
const input = page.locator('.MuiInputBase-root:has(.MuiInputAdornment-positionStart) input').first();
|
||||
const tFilter = Date.now();
|
||||
// свод 2 «после»: поиск уходит на сервер; нет запроса (сборка «до») - идём дальше
|
||||
// ожидание ответа идёт параллельно со стабилизацией: таймаут 5 с на «до» в метрику не попадает
|
||||
let respAt: number | null = null;
|
||||
const searchResp = t.kind === 'svod2'
|
||||
? page.waitForResponse((r) => isSvod2SearchUrl(r.url()), { timeout: 5_000 }).then(() => { respAt = Date.now(); }, () => {})
|
||||
: null;
|
||||
await input.fill('1');
|
||||
stable = await awaitStable(page, 120_000);
|
||||
const stableAt = Date.now();
|
||||
if (searchResp) await searchResp;
|
||||
if (t.kind === 'svod2') filterResultMs = filterResultMsOf(tFilter, stableAt, respAt);
|
||||
effect = (await signature(page)) !== sigBefore;
|
||||
if (!stable) reason = 'filter did not stabilize in 120s';
|
||||
else if (!effect) reason = 'filter_no_effect';
|
||||
} else {
|
||||
if (t.kind === 'svod2') {
|
||||
// сэмпл в requestAnimationFrame: виден ли хотя бы один ряд-заглушка
|
||||
await page.evaluate(([sel, ph]) => {
|
||||
const st = { on: true, samples: [] as boolean[] };
|
||||
const tick = () => {
|
||||
if (!st.on) return;
|
||||
st.samples.push(document.querySelector(`${sel} ${ph}`) !== null);
|
||||
requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
(window as any).__ph = st;
|
||||
}, [CONTAINER, SVOD2_PLACEHOLDER_SELECTOR]);
|
||||
}
|
||||
const r = await scrollThrough(page, CONTAINER, axis, 200, MAX_STEPS);
|
||||
if (t.kind === 'svod2') {
|
||||
placeholderSamples = await page.evaluate(() => { const st = (window as any).__ph; st.on = false; return st.samples as boolean[]; });
|
||||
}
|
||||
({ complete, steps } = r);
|
||||
if (r.reason) reason = r.reason;
|
||||
else if (!complete) reason = 'scroll_incomplete';
|
||||
}
|
||||
probe = await pr.stop();
|
||||
if (t.kind === 'svod2') await net.send('Network.disable').catch(() => {});
|
||||
if (trace) { await stopTrace(page, traceFile); artifacts.push(path.relative(runDir(), traceFile)); }
|
||||
const rowsAfter = await page.evaluate((sel) => document.querySelectorAll(`${sel} tbody tr`).length, CONTAINER);
|
||||
const rowsAfter = await page.evaluate(([sel, rs]) => document.querySelectorAll(`${sel} ${rs}`).length, [CONTAINER, rowSel]);
|
||||
const svod2Metrics = t.kind === 'svod2'
|
||||
? {
|
||||
first_rows_ms: firstRowsMs,
|
||||
// в записи фильтра счётчики включали бы исходную загрузку (до fill) - пишем их только в записи скролла
|
||||
...(filter
|
||||
? { filter_result_ms: filterResultMs ?? 0 }
|
||||
: {
|
||||
json_bytes_total: bytes.total,
|
||||
json_bytes_first: bytes.first,
|
||||
pages_requested: countPagesRequested(pageUrls),
|
||||
placeholder_frame_share: placeholderShare(placeholderSamples),
|
||||
}),
|
||||
}
|
||||
: {};
|
||||
append({
|
||||
...rec,
|
||||
metrics: { ...probe, ...perStep(probe, steps), axis: axis === 'y' ? 0 : 1, traced: trace ? 1 : 0, rows_visible_before: rows, rows_visible_after: rowsAfter, json_bytes_sheet: jsonBytes, steps },
|
||||
metrics: { ...probe, ...perStep(probe, steps), axis: axis === 'y' ? 0 : 1, traced: trace ? 1 : 0, rows_visible_before: rows, rows_visible_after: rowsAfter, json_bytes_sheet: bytes.sheet, steps, ...svod2Metrics },
|
||||
artifacts,
|
||||
incomplete: !complete || !stable || !effect,
|
||||
...(reason ? { skip_reason: reason } : {}),
|
||||
|
||||
110
perf/e2e/scenarios/svod2-memory.spec.ts
Normal file
110
perf/e2e/scenarios/svod2-memory.spec.ts
Normal file
@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Сценарий памяти свода 2 (Task 16): роль dfip, свежий контекст на каждый повтор, одна SPA-сессия внутри повтора.
|
||||
* Точки (lib/svod2.ts memoryPoints): go (вкладка GO, «Все ССП/РФ», «Загрузить») -> rf (вкладка RF, то же)
|
||||
* [-> rf_scrolled при PERF_SVOD2_MEM_SCROLL=1] -> final (возврат на вкладку GO; «Загрузить» не нажимается: выбор и данные хранятся по вкладкам).
|
||||
* В каждой точке gc -> heapUsed, domCounters. Запись: scenario "svod2-memory", target "svod2/<point>".
|
||||
* Env: PERF_SCALE, PERF_RUN_ID, PERF_REPEATS (3), PERF_SVOD2_MEM_SCROLL, PERF_LOAD_TIMEOUT_MS, PERF_MAX_STEPS,
|
||||
* PERF_TEST_TIMEOUT_MS.
|
||||
*/
|
||||
import { test, type Page } from '@playwright/test';
|
||||
import { loadManifest, scale } from '../lib/manifest';
|
||||
import { loginAs, WEB } from '../lib/auth';
|
||||
import { throttle, heapUsed, domCounters } from '../lib/cdp';
|
||||
import { append } from '../lib/results';
|
||||
import { scrollThrough } from '../lib/scroll';
|
||||
import { SVOD2_DATA_ROW_SELECTOR, SVOD2_PLACEHOLDER_SELECTOR, memoryPoints } from '../lib/svod2';
|
||||
|
||||
const CONTAINER = '.MuiTableContainer-root';
|
||||
const MIN_ROWS = 20;
|
||||
const REPEATS = Number(process.env.PERF_REPEATS ?? 3);
|
||||
const LOAD_TIMEOUT = Number(process.env.PERF_LOAD_TIMEOUT_MS ?? 900_000);
|
||||
const MAX_STEPS = Number(process.env.PERF_MAX_STEPS ?? 5000);
|
||||
|
||||
async function waitRows(page: Page, what: string): Promise<number> {
|
||||
const t0 = Date.now();
|
||||
for (;;) {
|
||||
const n = await page.evaluate(([sel, rs]) => document.querySelectorAll(`${sel} ${rs}`).length, [CONTAINER, SVOD2_DATA_ROW_SELECTOR]);
|
||||
if (n >= MIN_ROWS) return n;
|
||||
if (Date.now() - t0 > LOAD_TIMEOUT) throw new Error(`${what}: rows<${MIN_ROWS} за ${LOAD_TIMEOUT} мс (rows=${n})`);
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
}
|
||||
|
||||
/** Ждём окончания загрузки: число строк, заглушек и скелетонов не меняются 3 опроса подряд (интервал 300 мс). */
|
||||
async function settle(page: Page): Promise<number> {
|
||||
let prev = '';
|
||||
let same = 0;
|
||||
const t0 = Date.now();
|
||||
for (;;) {
|
||||
const cur = await page.evaluate(
|
||||
([sel, rs, ph]) => `${document.querySelectorAll(`${sel} ${rs}`).length}/${document.querySelectorAll(`${sel} ${ph}`).length}/${document.querySelectorAll(`${sel} .MuiSkeleton-root`).length}`,
|
||||
[CONTAINER, SVOD2_DATA_ROW_SELECTOR, SVOD2_PLACEHOLDER_SELECTOR],
|
||||
);
|
||||
same = cur === prev ? same + 1 : 0;
|
||||
prev = cur;
|
||||
if (same >= 3 && cur.endsWith('/0')) return Number(cur.split('/')[0]); // готово: строки стабильны и в контейнере 0 скелетонов
|
||||
if (Date.now() - t0 > LOAD_TIMEOUT) throw new Error(`settle: строки не стабилизировались за ${LOAD_TIMEOUT} мс (${cur})`);
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
}
|
||||
|
||||
/** «Все ССП/РФ» + «Загрузить» (Svod2Page.jsx), как в scroll.spec.ts. */
|
||||
async function loadAll(page: Page): Promise<void> {
|
||||
await page.getByLabel('ССП/РФ').click();
|
||||
await page.getByRole('option', { name: 'Все ССП/РФ' }).click();
|
||||
await page.keyboard.press('Escape');
|
||||
await page.getByRole('button', { name: 'Загрузить' }).click();
|
||||
}
|
||||
|
||||
for (let repeat = 0; repeat < REPEATS; repeat++) {
|
||||
test(`svod2-memory: dfip #${repeat}`, async ({ browser }) => {
|
||||
test.setTimeout(Number(process.env.PERF_TEST_TIMEOUT_MS ?? 3600_000));
|
||||
const user = loadManifest().users['dfip'];
|
||||
if (!user) throw new Error('в манифесте нет пользователя dfip');
|
||||
const points = memoryPoints(process.env);
|
||||
const ctx = await browser.newContext({ viewport: { width: 1600, height: 900 } });
|
||||
try {
|
||||
await loginAs(ctx, user);
|
||||
const page = await ctx.newPage();
|
||||
page.on('pageerror', (e) => console.log('pageerror:', e.message));
|
||||
await page.goto(WEB + '/svod/2');
|
||||
await throttle(page, 1);
|
||||
|
||||
const sample = async (point: string, rows: number) => {
|
||||
const heap = await heapUsed(page); // gc внутри
|
||||
const dom = await domCounters(page);
|
||||
append({
|
||||
scenario: 'svod2-memory', target: `svod2/${point}`, role: 'dfip', scale: scale(), cpu_rate: 1, repeat,
|
||||
metrics: { heap_used_mb: heap / 1048576, nodes: dom.nodes, documents: dom.documents, js_event_listeners: dom.jsEventListeners, rows_visible: rows },
|
||||
artifacts: [],
|
||||
});
|
||||
console.log(`${point} #${repeat}: heap ${(heap / 1048576).toFixed(1)} MB, nodes ${dom.nodes}, listeners ${dom.jsEventListeners}`);
|
||||
};
|
||||
|
||||
for (const point of points) {
|
||||
let rows: number;
|
||||
if (point === 'go') {
|
||||
await page.getByRole('tab').nth(0).click();
|
||||
await loadAll(page);
|
||||
rows = await waitRows(page, point);
|
||||
} else if (point === 'rf') {
|
||||
await page.getByRole('tab').nth(1).click();
|
||||
await loadAll(page);
|
||||
rows = await waitRows(page, point);
|
||||
} else if (point === 'rf_scrolled') {
|
||||
const r = await scrollThrough(page, CONTAINER, 'y', 200, MAX_STEPS);
|
||||
if (!r.complete) throw new Error(`rf_scrolled: скролл не дошёл до конца (${r.reason ?? 'incomplete'})`);
|
||||
rows = await waitRows(page, point);
|
||||
} else {
|
||||
// выбор и данные хранятся по вкладкам (SvodContext, useSvod2Pages): повторный loadAll снял бы «Все ССП/РФ»
|
||||
await page.getByRole('tab').nth(0).click();
|
||||
rows = await waitRows(page, point);
|
||||
}
|
||||
rows = await settle(page);
|
||||
await sample(point, rows);
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
81
perf/e2e/tests/svod2-metrics.test.ts
Normal file
81
perf/e2e/tests/svod2-metrics.test.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
ByteCounter,
|
||||
placeholderShare,
|
||||
isSvod2SearchUrl,
|
||||
svod2RespRe,
|
||||
countPagesRequested,
|
||||
filterResultMs,
|
||||
memoryPoints,
|
||||
SVOD2_DATA_ROW_SELECTOR,
|
||||
SVOD2_PLACEHOLDER_SELECTOR,
|
||||
} from '../lib/svod2.ts';
|
||||
|
||||
test('ByteCounter: sheet=max, total=sum, first=sum до markFirstRows', () => {
|
||||
const c = new ByteCounter();
|
||||
c.add('a', 10);
|
||||
c.add('b', 30);
|
||||
c.markFirstRows();
|
||||
c.add('c', 20);
|
||||
assert.equal(c.sheet, 30);
|
||||
assert.equal(c.total, 60);
|
||||
assert.equal(c.first, 40);
|
||||
});
|
||||
|
||||
test('ByteCounter: чанки одного запроса суммируются для sheet', () => {
|
||||
const c = new ByteCounter();
|
||||
c.add('a', 10);
|
||||
c.add('a', 15);
|
||||
assert.equal(c.sheet, 25);
|
||||
assert.equal(c.total, 25);
|
||||
});
|
||||
|
||||
test('placeholderShare', () => {
|
||||
assert.equal(placeholderShare([true, false, false, false]), 0.25);
|
||||
assert.equal(placeholderShare([]), 0);
|
||||
});
|
||||
|
||||
test('isSvod2SearchUrl', () => {
|
||||
assert.equal(isSvod2SearchUrl('http://h/api/v1/svod/2/GO/2026?view=compact&search=1'), true);
|
||||
assert.equal(isSvod2SearchUrl('http://h/api/v1/svod/2/GO/2026?view=compact'), false);
|
||||
assert.equal(isSvod2SearchUrl('http://h/api/v1/svod/1/GO/2026?search=1'), false);
|
||||
});
|
||||
|
||||
test('svod2RespRe', () => {
|
||||
const re = svod2RespRe('GO');
|
||||
assert.ok(re.test('http://h/api/v1/svod/2/GO/2026?offset=0'));
|
||||
assert.ok(re.test('http://h/api/v1/svod/2/GO/2026/facets'));
|
||||
assert.ok(!re.test('http://h/api/v1/svod/2/RF/2026'));
|
||||
});
|
||||
|
||||
test('countPagesRequested', () => {
|
||||
assert.equal(
|
||||
countPagesRequested([
|
||||
'http://h/api/v1/svod/2/GO/2026?offset=0&limit=100',
|
||||
'http://h/api/v1/svod/2/GO/2026?limit=100&offset=100',
|
||||
'http://h/api/v1/svod/2/GO/2026/facets',
|
||||
]),
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
test('селекторы', () => {
|
||||
assert.equal(SVOD2_DATA_ROW_SELECTOR, 'tbody tr:not([data-svod2-placeholder]):not(:has(.MuiSkeleton-root))');
|
||||
assert.equal(SVOD2_PLACEHOLDER_SELECTOR, 'tbody tr[data-svod2-placeholder]');
|
||||
});
|
||||
|
||||
test('filterResultMs: нет запроса поиска - только стабилизация', () => {
|
||||
assert.equal(filterResultMs(1000, 1700, null), 700);
|
||||
assert.equal(filterResultMs(1000, 1700, 1200), 700);
|
||||
assert.equal(filterResultMs(1000, 1700, 1900), 900);
|
||||
});
|
||||
|
||||
test('memoryPoints: по умолчанию go, rf, final', () => {
|
||||
assert.deepEqual(memoryPoints({}), ['go', 'rf', 'final']);
|
||||
assert.deepEqual(memoryPoints({ PERF_SVOD2_MEM_SCROLL: '0' }), ['go', 'rf', 'final']);
|
||||
});
|
||||
|
||||
test('memoryPoints: PERF_SVOD2_MEM_SCROLL=1 добавляет rf_scrolled', () => {
|
||||
assert.deepEqual(memoryPoints({ PERF_SVOD2_MEM_SCROLL: '1' }), ['go', 'rf', 'rf_scrolled', 'final']);
|
||||
});
|
||||
24
perf/tests/test_svod2_analysis.py
Normal file
24
perf/tests/test_svod2_analysis.py
Normal file
@ -0,0 +1,24 @@
|
||||
"""Самопроверка разборщиков свода 2 в perf/analysis (без стенда и БД)."""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "analysis"))
|
||||
|
||||
import explain_manual # noqa: E402
|
||||
import scale_compare # noqa: E402
|
||||
|
||||
|
||||
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 страница compact"
|
||||
assert scale_compare.svod2_key("/api/v1/svod/2/RF/2026?view=full&offset=0&limit=300") == "GET свод 2 RF страница full"
|
||||
assert scale_compare.svod2_key("/api/v1/svod/2/RF/2026?offset=0&limit=300") == "GET свод 2 RF страница"
|
||||
assert scale_compare.svod2_key("/api/v1/svod/2/RF/2026?view=compact&limit=300&search=1") == "GET свод 2 RF страница compact поиск"
|
||||
assert scale_compare.svod2_key("/api/v1/svod/2/GO/2026?view=full") == "GET свод 2 GO full"
|
||||
assert scale_compare.svod2_key("/api/v1/svod/2/GO/2026?search=1") == "GET свод 2 GO поиск"
|
||||
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
|
||||
44
perf/tests/test_svod2_paging_check.py
Normal file
44
perf/tests/test_svod2_paging_check.py
Normal file
@ -0,0 +1,44 @@
|
||||
"""Самопроверка чистых функций svod2_paging_check (без стенда)."""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "analysis"))
|
||||
|
||||
import svod2_paging_check as chk # noqa: E402
|
||||
|
||||
|
||||
def _row(i, **data):
|
||||
return {"row_type": "row", "depth": 0, "sort_order": i, "data": {"line_id": i, **data}}
|
||||
|
||||
|
||||
def test_stitch_pages_seven_rows_limit_three():
|
||||
rows = [_row(i) for i in range(7)]
|
||||
calls = []
|
||||
|
||||
def fetch(offset, limit):
|
||||
calls.append((offset, limit))
|
||||
return {"result": rows[offset : offset + limit], "total": 7}
|
||||
|
||||
stitched, totals = chk.stitch_pages(fetch, 3)
|
||||
assert stitched == rows
|
||||
assert totals == {7}
|
||||
|
||||
|
||||
def test_compare_stitched_catches_gap_and_duplicate():
|
||||
full = [_row(i) for i in range(5)]
|
||||
assert chk.compare_stitched(full, list(full)) == []
|
||||
assert chk.compare_stitched(full, full[:2] + full[3:]) # пропуск
|
||||
assert chk.compare_stitched(full, full[:3] + [full[2]] + full[3:]) # повтор
|
||||
|
||||
|
||||
def test_check_compact_catches_extra_key_and_lost_zero():
|
||||
full = [_row(1, header={"name": "x"}, plan={"plan_q1": 0, "plan_q2": 5})]
|
||||
proj = chk.compact_projection(full[0]["data"])
|
||||
assert proj["plan"]["plan_q1"] == 0
|
||||
ok = [{"row_type": "row", "depth": 0, "sort_order": 1, "data": proj}]
|
||||
assert chk.check_compact(full, ok) == []
|
||||
extra = [{**ok[0], "data": {**proj, "zzz_extra": 1}}]
|
||||
assert chk.check_compact(full, extra)
|
||||
lost = [{**ok[0], "data": {**proj, "plan": {"plan_q2": 5}}}]
|
||||
assert chk.check_compact(full, lost)
|
||||
assert chk.check_compact(full, []) # потерянная строка
|
||||
Loading…
x
Reference in New Issue
Block a user