From a663da505af4ed716085d145bd2eb303fecedf27 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 29 Oct 2026 00:23:34 +0400 Subject: [PATCH] =?UTF-8?q?perf:=20=D1=81=D0=B2=D0=BE=D0=B4=202=20?= =?UTF-8?q?=E2=80=94=20=D1=81=D0=B2=D0=B5=D1=80=D0=BA=D0=B0=20=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=B0=D0=BD=D0=B8=D1=86,=20=D0=BF=D0=BE=D0=B8=D1=81?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B8=20=D0=B2=D0=B0=D1=80=D0=B8=D0=B0=D0=BD?= =?UTF-8?q?=D1=82=D0=BE=D0=B2=20=D1=81=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=BE=D0=BC=20=C2=AB=D0=B4=D0=BE=C2=BB=20=D0=BD=D0=B0=20=D0=B4?= =?UTF-8?q?=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20=D1=81=D1=82=D0=B5=D0=BD=D0=B4?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- perf/analysis/svod2_paging_check.py | 136 +++++++++++++++++ perf/analysis/svod2_search_oracle.mjs | 162 +++++++++++++++++++++ perf/analysis/svod2_search_oracle.test.mjs | 88 +++++++++++ perf/tests/test_svod2_paging_check.py | 44 ++++++ 4 files changed, 430 insertions(+) create mode 100644 perf/analysis/svod2_paging_check.py create mode 100644 perf/tests/test_svod2_paging_check.py diff --git a/perf/analysis/svod2_paging_check.py b/perf/analysis/svod2_paging_check.py new file mode 100644 index 0000000..80a3b3b --- /dev/null +++ b/perf/analysis/svod2_paging_check.py @@ -0,0 +1,136 @@ +"""Сверка серверной постраничной выдачи свода 2 с прежним полным ответом на данных стенда (только чтение). + + cd perf && python analysis/svod2_paging_check.py --scale 1 [--api http://localhost:8000] + +Пользователь dfip и год берутся из perf/results/manifest-s.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()) diff --git a/perf/analysis/svod2_search_oracle.mjs b/perf/analysis/svod2_search_oracle.mjs index 2343a53..8124952 100644 --- a/perf/analysis/svod2_search_oracle.mjs +++ b/perf/analysis/svod2_search_oracle.mjs @@ -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); +} diff --git a/perf/analysis/svod2_search_oracle.test.mjs b/perf/analysis/svod2_search_oracle.test.mjs index 749a8a5..2bf20fb 100644 --- a/perf/analysis/svod2_search_oracle.test.mjs +++ b/perf/analysis/svod2_search_oracle.test.mjs @@ -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(); + } +}); diff --git a/perf/tests/test_svod2_paging_check.py b/perf/tests/test_svod2_paging_check.py new file mode 100644 index 0000000..25b750a --- /dev/null +++ b/perf/tests/test_svod2_paging_check.py @@ -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, []) # потерянная строка