perf: сценарий памяти
Refs #202 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
6a2439239d
commit
fa5dff8c6a
@ -162,6 +162,9 @@
|
||||
| 2026-10-26 | Строки grow пустые (значения не заполняются), FORM_2: дубли ВСП допустимы (API принимает) | Для открытия модалки важно число строк в DOM/таблице, не данные | Нет |
|
||||
| 2026-10-26 | Время grow на масштабе 1: FORM_2 +468/+469 строк по ~1 c (параллельно), FORM_4 CAP +190 строк 213 c (последовательно, ~1.1 c на строку, O(n)) | Замер | Для масштаба 2 цели те же - время не растёт |
|
||||
| 2026-10-26 | Порядок run-all (предложение для T10): reset -> gen -> скролл -> соавтор -> grow -> модалки (малые и @big) -> память | grow меняет размер листов | Решение за T10 |
|
||||
| 2026-10-26 | Сценарий памяти: вместо «самого большого листа FORM_1» берётся самый большой лист FORM_2 с owner=rf (AHR, 1154 строки; на нём 3 правки за цикл), вместо FORM_2 - самый большой лист FORM_4 с owner=rf (STRUCTURE, 775 строк; только открытие и скролл, правок нет). Переопределение: `PERF_MEM_SHEET1/2` | У роли rf нет листов FORM_1 (они ССП, 404/нет прав) | Нагрузка цикла отличается от формулировки брифа; пара листов меняется одной переменной |
|
||||
| 2026-10-26 | Правка ячейки в сценарии памяти - двойной клик (material-react-table, editDisplayMode cell), числа 1000+cycle*10+i, Enter; успех = эхо cell_updated is_self без error + закрытие редактора. Скролл `scrollThrough` идёт вниз и обратно | Так редактор открывается в приложении; эхо подтверждает прохождение через фронтовый WS | Значения ячеек rf на листах меняются (стенд тестовый) |
|
||||
| 2026-10-26 | Навигация между страницами - history.pushState + popstate, перед выборкой проверяется маркер `window.__sessionMark`; перед каждым циклом access-токен перевыпускается через API, если до истечения (60 мин, ACCESS_TOKEN_EXPIRE_MINUTES) меньше 10 мин | Одна SPA-сессия; полный сценарий дольше жизни токена (минор T4.1: init-script не перезаписывает токен без перезагрузок) | Без перелогина правки после 60-й минуты падали бы по токену |
|
||||
|
||||
|
||||
## Decisions made with the human
|
||||
|
||||
@ -86,3 +86,19 @@ cd .. && .venv/bin/python -m perfkit.modals_inventory results/m
|
||||
- Оценка полной матрицы на CPU 1: priority 1 (17 целей x 5) около 6 мин, priority 2 (15 x 3) около 2 мин, profiling около 2 мин; всего 10-12 мин. С `PERF_CPU_RATES=1,4,6` priority 1 вырастет втрое.
|
||||
|
||||
Малый и большой лист. Форм-модалки (выбор ВСП/статьи, создание программы/проекта) существуют только на листах FORM_2 AHR_RENT/UTILITY/SECURITY (31 строка) и FORM_4 AHR/CAP/OPER (110 строк). Для зависимости от числа строк `perfkit.gen.grow` (`cd perf && .venv/bin/python -m perfkit.gen.grow --scale 1`, идемпотентно, ~4 мин, FORM_4 add_line O(n)) растит FORM_2 AHR_UTILITY/AHR_SECURITY до 500 и FORM_4 CAP до 300 строк и обновляет манифест. В `modals.json` у этих модалок `big_sheet`: пара записей `<id>` (малый лист: AHR_RENT, FORM_4/AHR) и `<id>@big` (UTILITY, FORM_4/CAP). Порядок для run-all: reset -> gen -> скролл -> соавтор -> grow -> модалки; малые замеры от grow не зависят, но скролл по UTILITY/SECURITY/CAP после grow несопоставим с до него.
|
||||
|
||||
### Память
|
||||
|
||||
`perf/e2e/scenarios/memory.spec.ts` - одна SPA-сессия роли `rf` на CPU 1 без перезагрузки страницы, 20 циклов + базовая точка (цикл 0) = 21 запись `scenario: memory`. Цикл: самый большой лист FORM_2 owner=rf (AHR) -> скролл вниз/обратно -> 3 правки ячеек двойным кликом (идут через WS, ждём эхо) -> `/tasks` -> самый большой лист FORM_4 owner=rf (STRUCTURE) -> скролл -> `/tasks` -> gc -> метрики. Навигация - history API (клиентский роутер). Листы FORM_1 у `rf` недоступны, поэтому вместо них FORM_2/FORM_4.
|
||||
|
||||
```
|
||||
cd perf/e2e
|
||||
PERF_RUN_ID=mem npx playwright test scenarios/memory.spec.ts # полный прогон, около часа
|
||||
PERF_RUN_ID=memsmall PERF_MEM_CYCLES=2 PERF_MEM_SNAPSHOTS=0,2 npx playwright test scenarios/memory.spec.ts # малый
|
||||
cd .. && .venv/bin/python -m perfkit.memtrend results/mem # наклоны МНК на цикл
|
||||
```
|
||||
|
||||
- Метрики записи: `cycle`, `heap_used_mb` (после gc), `documents`, `nodes`, `js_event_listeners` (CDP `Memory.getDOMCounters`), `ws_open` (обёртка над `window.WebSocket`), `ws_created`, `edits_ok`, `cycle_s`, `relogin`.
|
||||
- Heap snapshots на циклах 0, 10, 20 (`PERF_MEM_SNAPSHOTS`) - `perf/results/<run_id>/artifacts/memory-cycleNN.heapsnapshot`, в git не попадают; открываются в Chrome DevTools (Memory). Разбор retainers - Task 10.
|
||||
- Переменные: `PERF_MEM_CYCLES` (20), `PERF_MEM_SHEET1/2` (`FORM_x/SHEET/direction|null`), `PERF_TEST_TIMEOUT_MS` (3 ч). Токен (60 мин) перевыпускается по API без перезагрузки, если до конца < 10 мин.
|
||||
- Любая неудачная правка (нет эха, отказ сервера, нет редактируемых ячеек) - ошибка сценария.
|
||||
|
||||
232
perf/e2e/scenarios/memory.spec.ts
Normal file
232
perf/e2e/scenarios/memory.spec.ts
Normal file
@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Сценарий «память при длительной работе» (Task 8): одна SPA-сессия роли rf без перезагрузки страницы,
|
||||
* N циклов (PERF_MEM_CYCLES, по умолчанию 20) + базовая точка (цикл 0) = N+1 запись scenario "memory".
|
||||
*
|
||||
* Цикл: самый большой лист №1 -> скролл вниз (и обратно) -> 3 правки ячеек через UI -> /tasks ->
|
||||
* самый большой лист №2 -> скролл -> /tasks -> gc -> метрики.
|
||||
* Листы: у rf нет листов FORM_1 (они ССП), поэтому берутся самые большие листы owner=rf: №1 - FORM_2 (AHR),
|
||||
* №2 - FORM_4 (PERF_MEM_SHEET1 / PERF_MEM_SHEET2 - переопределение `<form_type>/<sheet>/<direction|null>`).
|
||||
* Навигация - клиентский роутер (history.pushState + popstate), не page.goto.
|
||||
* Правка: клик по редактируемой числовой ячейке (в приложении редактор открывается кликом), ввод числа, Enter;
|
||||
* ждём эхо cell_updated is_self=true от сервера по WS. Не удалось - сценарий падает.
|
||||
* Метрики: cycle, heap_used_mb (после gc), documents, nodes, js_event_listeners (CDP Memory.getDOMCounters),
|
||||
* ws_open (обёртка над window.WebSocket), edits_ok. Heap snapshots на циклах 0, 10, 20 (artifacts).
|
||||
* Env: PERF_SCALE, PERF_RUN_ID, PERF_MEM_CYCLES, PERF_MEM_SNAPSHOTS ('0,10,20'), PERF_CPU_RATES (только '1'),
|
||||
* PERF_LOAD_TIMEOUT_MS, PERF_MAX_STEPS, PERF_TEST_TIMEOUT_MS (3 ч).
|
||||
*/
|
||||
import { test, type Page } from '@playwright/test';
|
||||
import path from 'node:path';
|
||||
import { loadManifest, scale, type Sheet } from '../lib/manifest';
|
||||
import { loginAs, WEB, API, ACCESS_KEY, REFRESH_KEY } from '../lib/auth';
|
||||
import { throttle, heapUsed, domCounters, heapSnapshot } from '../lib/cdp';
|
||||
import { append, runDir } from '../lib/results';
|
||||
import { scrollThrough } from '../lib/scroll';
|
||||
|
||||
const CONTAINER = '.MuiTableContainer-root';
|
||||
const MIN_ROWS = 20;
|
||||
const EDITS_PER_SHEET = 3;
|
||||
const CYCLES = Number(process.env.PERF_MEM_CYCLES ?? 20);
|
||||
const SNAP_CYCLES = (process.env.PERF_MEM_SNAPSHOTS ?? '0,10,20').split(',').map((s) => Number(s.trim()));
|
||||
const LOAD_TIMEOUT = Number(process.env.PERF_LOAD_TIMEOUT_MS ?? 900_000);
|
||||
const MAX_STEPS = Number(process.env.PERF_MAX_STEPS ?? 5000);
|
||||
const TOKEN_MIN_LEFT_S = 600;
|
||||
|
||||
const sheetName = (s: Sheet) => `${s.form_type}/${s.sheet}/${s.direction ?? 'null'}`;
|
||||
const safe = (s: string) => s.replace(/[^\w.-]+/g, '_');
|
||||
|
||||
function pickSheet(formType: string, override?: string): Sheet {
|
||||
const all = loadManifest().sheets.filter((s) => s.owner === 'rf');
|
||||
if (override) {
|
||||
const s = all.find((x) => sheetName(x) === override);
|
||||
if (!s) throw new Error(`лист ${override} не найден среди owner=rf`);
|
||||
return s;
|
||||
}
|
||||
const best = all.filter((s) => s.form_type === formType).sort((a, b) => b.rows - a.rows)[0];
|
||||
if (!best) throw new Error(`нет листов ${formType} с owner=rf`);
|
||||
return best;
|
||||
}
|
||||
|
||||
const WS_SCRIPT = `(() => {
|
||||
if (window.__wsStat) return;
|
||||
const st = { open: 0, created: 0, closed: 0 };
|
||||
const Orig = window.WebSocket;
|
||||
function Wrapped(url, protocols) {
|
||||
const ws = protocols === undefined ? new Orig(url) : new Orig(url, protocols);
|
||||
st.created++;
|
||||
let counted = false;
|
||||
ws.addEventListener('open', () => { counted = true; st.open++; });
|
||||
ws.addEventListener('close', () => { if (counted) { counted = false; st.open--; st.closed++; } });
|
||||
return ws;
|
||||
}
|
||||
Wrapped.prototype = Orig.prototype;
|
||||
for (const k of ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED']) Wrapped[k] = Orig[k];
|
||||
window.WebSocket = Wrapped;
|
||||
window.__wsStat = st;
|
||||
})();`;
|
||||
|
||||
/** Клиентская навигация без перезагрузки: pushState + popstate (BrowserRouter реагирует на popstate). */
|
||||
async function spaGo(page: Page, url: string): Promise<void> {
|
||||
await page.evaluate((u) => {
|
||||
window.history.pushState({}, '', u);
|
||||
window.dispatchEvent(new PopStateEvent('popstate', { state: {} }));
|
||||
}, url);
|
||||
}
|
||||
|
||||
async function waitRows(page: Page, what: string): Promise<number> {
|
||||
const t0 = Date.now();
|
||||
for (;;) {
|
||||
const n = await page.evaluate((sel) => document.querySelectorAll(`${sel} tbody tr`).length, CONTAINER);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitTasks(page: Page): Promise<void> {
|
||||
await page.waitForFunction(() => location.pathname === '/tasks' && document.querySelector('#root')!.children.length > 0, null, { timeout: 60_000 });
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
/** Если access-токен скоро истечёт - перелогиниться по API и положить токены в localStorage (без перезагрузки). */
|
||||
async function ensureToken(page: Page, user: { username: string; password: string }): Promise<boolean> {
|
||||
const left = await page.evaluate((k) => {
|
||||
try {
|
||||
const t = localStorage.getItem(k);
|
||||
if (!t) return -1;
|
||||
const p = JSON.parse(atob(t.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')));
|
||||
return p.exp - Date.now() / 1000;
|
||||
} catch { return -1; }
|
||||
}, ACCESS_KEY);
|
||||
if (left > TOKEN_MIN_LEFT_S) return false;
|
||||
const r = await page.context().request.post(`${API}/api/v1/auth/login`, { data: { username: user.username, password: user.password } });
|
||||
if (!r.ok()) throw new Error(`relogin ${user.username}: ${r.status()}`);
|
||||
const d = await r.json();
|
||||
await page.evaluate(([a, rk, at, rt]) => {
|
||||
localStorage.setItem(a, at);
|
||||
if (rt) localStorage.setItem(rk, rt);
|
||||
}, [ACCESS_KEY, REFRESH_KEY, d.access_token, d.refresh_token ?? ''] as const);
|
||||
return true;
|
||||
}
|
||||
|
||||
interface WsLog { updates: Array<{ self: boolean; error: boolean }> }
|
||||
|
||||
/** Правка n числовых редактируемых ячеек среди видимых строк: клик, ввод, Enter, ожидание эха. */
|
||||
async function editCells(page: Page, wsLog: WsLog, n: number, seed: number, what: string): Promise<void> {
|
||||
// кандидаты: ячейки с cursor:pointer (редактируемые, не заблокированные), числовой текст
|
||||
const marks = await page.evaluate(({ sel, need }) => {
|
||||
const cont = document.querySelector(sel) as HTMLElement;
|
||||
const cr = cont.getBoundingClientRect();
|
||||
const out: Array<{ r: number; c: number; row: string }> = [];
|
||||
document.querySelectorAll(`${sel} tbody tr`).forEach((tr, ri) => {
|
||||
const b = tr.getBoundingClientRect();
|
||||
if (b.top < cr.top + 80 || b.bottom > cr.bottom - 10) return;
|
||||
tr.querySelectorAll('div.cell').forEach((el, ci) => {
|
||||
const e = el as HTMLElement;
|
||||
if (e.style.cursor !== 'pointer') return;
|
||||
if (e.querySelector('span div')) return;
|
||||
const t = (e.textContent ?? '').trim();
|
||||
if (!/^-?[\d\s .,]*$/.test(t) || t === '') return;
|
||||
if (out.filter((o) => o.r === ri).length) return; // не больше одной на строку
|
||||
out.push({ r: ri, c: ci, row: t });
|
||||
});
|
||||
});
|
||||
return out.slice(0, need);
|
||||
}, { sel: CONTAINER, need: n });
|
||||
if (marks.length < n) throw new Error(`${what}: найдено ${marks.length} редактируемых числовых ячеек из ${n}`);
|
||||
for (let i = 0; i < marks.length; i++) {
|
||||
const m = marks[i];
|
||||
const cell = page.locator(`${CONTAINER} tbody tr`).nth(m.r).locator('div.cell').nth(m.c);
|
||||
const before = wsLog.updates.length;
|
||||
await cell.dblclick();
|
||||
const ta = page.locator('textarea').first();
|
||||
await ta.waitFor({ state: 'visible', timeout: 10_000 });
|
||||
await ta.fill(String(1000 + seed * 10 + i));
|
||||
await ta.press('Enter');
|
||||
const t0 = Date.now();
|
||||
while (wsLog.updates.length === before) {
|
||||
if (Date.now() - t0 > 15_000) throw new Error(`${what}: нет эха cell_updated за 15 с (правка ${i + 1}/${n})`);
|
||||
await page.waitForTimeout(50);
|
||||
}
|
||||
const u = wsLog.updates.slice(before).find((x) => x.self);
|
||||
if (!u) throw new Error(`${what}: эхо без is_self (правка ${i + 1})`);
|
||||
if (u.error) throw new Error(`${what}: сервер отклонил правку (${i + 1}/${n})`);
|
||||
await ta.waitFor({ state: 'detached', timeout: 10_000 }); // редактор закрыт - правка сохранена
|
||||
}
|
||||
}
|
||||
|
||||
async function openAndScroll(page: Page, s: Sheet, what: string): Promise<number> {
|
||||
await spaGo(page, s.url);
|
||||
const rows = await waitRows(page, what);
|
||||
await page.waitForTimeout(300);
|
||||
const r = await scrollThrough(page, CONTAINER, 'y', 200, MAX_STEPS);
|
||||
if (!r.complete) throw new Error(`${what}: скролл не дошёл до конца (${r.reason ?? 'incomplete'})`);
|
||||
return rows;
|
||||
}
|
||||
|
||||
test('memory: 20 циклов, rf, CPU 1', async ({ browser }) => {
|
||||
test.setTimeout(Number(process.env.PERF_TEST_TIMEOUT_MS ?? 3 * 3600_000));
|
||||
const cpu = Number(process.env.PERF_CPU_RATES ?? 1);
|
||||
if (cpu !== 1) throw new Error('сценарий памяти идёт только на CPU 1 (решение человека)');
|
||||
const m = loadManifest();
|
||||
const user = m.users['rf'];
|
||||
const sheet1 = pickSheet('FORM_2', process.env.PERF_MEM_SHEET1);
|
||||
const sheet2 = pickSheet('FORM_4', process.env.PERF_MEM_SHEET2);
|
||||
const ctx = await browser.newContext({ viewport: { width: 1600, height: 900 } });
|
||||
try {
|
||||
await loginAs(ctx, user);
|
||||
await ctx.addInitScript(WS_SCRIPT);
|
||||
const page = await ctx.newPage();
|
||||
const wsLog: WsLog = { updates: [] };
|
||||
page.on('websocket', (ws) => ws.on('framereceived', (f) => {
|
||||
const p = typeof f.payload === 'string' ? f.payload : f.payload.toString();
|
||||
if (!p.includes('cell_updated')) return;
|
||||
try {
|
||||
const d = JSON.parse(p);
|
||||
if (d.event === 'cell_updated') wsLog.updates.push({ self: d.is_self === true, error: !!d.error });
|
||||
} catch { /* не JSON */ }
|
||||
}));
|
||||
page.on('pageerror', (e) => console.log('pageerror:', e.message));
|
||||
|
||||
await page.goto(WEB + '/tasks'); // единственная загрузка страницы за сценарий
|
||||
await waitTasks(page);
|
||||
await page.evaluate(() => { (window as any).__sessionMark = 1; }); // пропадёт при перезагрузке страницы
|
||||
await throttle(page, 1);
|
||||
|
||||
const sample = async (cycle: number, extra: Record<string, number>) => {
|
||||
const heap = await heapUsed(page); // gc внутри
|
||||
const dom = await domCounters(page);
|
||||
const ws = await page.evaluate(() => (window as any).__wsStat);
|
||||
const artifacts: string[] = [];
|
||||
if (SNAP_CYCLES.includes(cycle)) {
|
||||
const f = path.join(runDir(), 'artifacts', `memory-cycle${String(cycle).padStart(2, '0')}.heapsnapshot`);
|
||||
await heapSnapshot(page, f);
|
||||
artifacts.push(path.relative(runDir(), f));
|
||||
}
|
||||
append({
|
||||
scenario: 'memory', target: `${safe(sheetName(sheet1))}+${safe(sheetName(sheet2))}`, role: 'rf', scale: scale(), cpu_rate: 1, repeat: 0,
|
||||
metrics: {
|
||||
cycle, heap_used_mb: heap / 1048576, documents: dom.documents, nodes: dom.nodes, js_event_listeners: dom.jsEventListeners,
|
||||
ws_open: ws.open, ws_created: ws.created, ...extra,
|
||||
},
|
||||
artifacts,
|
||||
});
|
||||
console.log(`cycle ${cycle}: heap ${(heap / 1048576).toFixed(1)} MB, nodes ${dom.nodes}, listeners ${dom.jsEventListeners}, ws_open ${ws.open}`);
|
||||
};
|
||||
|
||||
await sample(0, {});
|
||||
for (let cycle = 1; cycle <= CYCLES; cycle++) {
|
||||
const t0 = Date.now();
|
||||
const relogin = await ensureToken(page, user);
|
||||
await openAndScroll(page, sheet1, `цикл ${cycle} ${sheetName(sheet1)}`);
|
||||
await editCells(page, wsLog, EDITS_PER_SHEET, cycle, `цикл ${cycle} ${sheetName(sheet1)}`);
|
||||
await spaGo(page, '/tasks'); await waitTasks(page);
|
||||
await openAndScroll(page, sheet2, `цикл ${cycle} ${sheetName(sheet2)}`);
|
||||
await spaGo(page, '/tasks'); await waitTasks(page);
|
||||
await page.waitForTimeout(500); // дать закрыться WS и отработать размонтированию
|
||||
if (!(await page.evaluate(() => (window as any).__sessionMark === 1))) throw new Error('страница перезагружалась: сценарий требует одну SPA-сессию');
|
||||
await sample(cycle, { edits_ok: EDITS_PER_SHEET, relogin: relogin ? 1 : 0, cycle_s: (Date.now() - t0) / 1000 });
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
54
perf/perfkit/memtrend.py
Normal file
54
perf/perfkit/memtrend.py
Normal file
@ -0,0 +1,54 @@
|
||||
"""Тренд памяти по записям сценария memory (Task 8).
|
||||
|
||||
CLI: python -m perfkit.memtrend <run_dir>
|
||||
Читает <run_dir>/memory.jsonl, сортирует записи по metrics.cycle и печатает наклон МНК
|
||||
(единиц метрики на цикл) для heap_used_mb, nodes, js_event_listeners.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
METRICS = ("heap_used_mb", "nodes", "js_event_listeners")
|
||||
|
||||
|
||||
def slope_per_cycle(values: list[float]) -> float:
|
||||
"""Наклон прямой МНК по точкам (0, v0), (1, v1), ... Меньше двух точек - ValueError."""
|
||||
n = len(values)
|
||||
if n < 2:
|
||||
raise ValueError("нужно минимум две точки")
|
||||
mx = (n - 1) / 2
|
||||
my = sum(values) / n
|
||||
sxx = sum((i - mx) ** 2 for i in range(n))
|
||||
sxy = sum((i - mx) * (v - my) for i, v in enumerate(values))
|
||||
return sxy / sxx
|
||||
|
||||
|
||||
def load_memory(run_dir) -> list[dict]:
|
||||
f = Path(run_dir) / "memory.jsonl"
|
||||
recs = []
|
||||
for line in f.read_text(encoding="utf-8").splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
r = json.loads(line)
|
||||
if r.get("scenario") == "memory":
|
||||
recs.append(r)
|
||||
return sorted(recs, key=lambda r: r["metrics"]["cycle"])
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
argv = sys.argv[1:] if argv is None else argv
|
||||
if len(argv) != 1:
|
||||
print("usage: python -m perfkit.memtrend <run_dir>", file=sys.stderr)
|
||||
return 2
|
||||
recs = load_memory(argv[0])
|
||||
print(f"циклов (записей): {len(recs)}")
|
||||
for m in METRICS:
|
||||
vals = [float(r["metrics"][m]) for r in recs]
|
||||
print(f"{m}: {slope_per_cycle(vals):.3f} на цикл (первый {vals[0]:.1f}, последний {vals[-1]:.1f})")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
38
perf/tests/test_memtrend.py
Normal file
38
perf/tests/test_memtrend.py
Normal file
@ -0,0 +1,38 @@
|
||||
import json
|
||||
import pytest
|
||||
|
||||
from perfkit.memtrend import slope_per_cycle, main
|
||||
|
||||
|
||||
def test_slope_linear():
|
||||
assert slope_per_cycle([10, 11, 12, 13]) == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_slope_flat():
|
||||
assert slope_per_cycle([5, 5, 5]) == 0.0
|
||||
|
||||
|
||||
def test_slope_noisy_is_least_squares():
|
||||
# y = 2x + шум; МНК, а не (last-first)/n
|
||||
assert slope_per_cycle([0, 3, 3, 7, 8]) == pytest.approx(2.0)
|
||||
|
||||
|
||||
def test_slope_needs_two_points():
|
||||
with pytest.raises(ValueError):
|
||||
slope_per_cycle([1.0])
|
||||
with pytest.raises(ValueError):
|
||||
slope_per_cycle([])
|
||||
|
||||
|
||||
def test_cli_prints_three_slopes(tmp_path, capsys):
|
||||
rows = [
|
||||
{"scenario": "memory", "repeat": 0, "metrics": {"cycle": c, "heap_used_mb": 100 + 2 * c, "nodes": 1000 + 5 * c, "js_event_listeners": 50}}
|
||||
for c in range(4)
|
||||
]
|
||||
# записи могут идти не по порядку и вперемешку с чужим сценарием
|
||||
lines = [json.dumps(r) for r in reversed(rows)] + [json.dumps({"scenario": "other", "metrics": {"cycle": 0}})]
|
||||
(tmp_path / "memory.jsonl").write_text("\n".join(lines) + "\n")
|
||||
assert main([str(tmp_path)]) == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "heap_used_mb" in out and "nodes" in out and "js_event_listeners" in out
|
||||
assert "2.000" in out and "5.000" in out and "0.000" in out
|
||||
Loading…
x
Reference in New Issue
Block a user