perf: свод 2 — сверка страниц, поиска и вариантов с экраном «до» на данных стенда
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
fbc3601673
commit
a663da505a
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)) {
|
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||||
const i = process.argv.indexOf('--emit-vectors');
|
const i = process.argv.indexOf('--emit-vectors');
|
||||||
if (i > 0 && process.argv[i + 1]) {
|
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`);
|
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 assert from 'node:assert/strict';
|
||||||
|
import http from 'node:http';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import {
|
import {
|
||||||
|
SYNTHETIC_ROWS,
|
||||||
|
compareCase,
|
||||||
|
deriveCases,
|
||||||
|
runCheck,
|
||||||
emitNumberVectors,
|
emitNumberVectors,
|
||||||
emitScreenVectors,
|
emitScreenVectors,
|
||||||
equals,
|
equals,
|
||||||
@ -58,3 +63,86 @@ test('векторы экрана: не меньше 12 случаев', () => {
|
|||||||
assert.ok(s.cases.length >= 12);
|
assert.ok(s.cases.length >= 12);
|
||||||
assert.ok(s.rows.length >= 10 && s.rows.length <= 15);
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
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