1
0

tester.html 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491
  1. <!DOCTYPE html>
  2. <html lang="ru">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  6. <title>Диводел — Испытательный стенд</title>
  7. <style>
  8. :root {
  9. --bg: #f5f0e1;
  10. --paper: #fdfaf2;
  11. --ink: #2a2419;
  12. --accent: #8b1a1a;
  13. --accent-light: #b84242;
  14. --ok: #2e7d32;
  15. --fail: #c62828;
  16. --warn: #ef6c00;
  17. --border: #d4c9a8;
  18. }
  19. * { box-sizing: border-box; }
  20. body {
  21. margin: 0;
  22. font-family: Georgia, 'Times New Roman', serif;
  23. background: var(--bg);
  24. color: var(--ink);
  25. line-height: 1.5;
  26. }
  27. header {
  28. background: var(--accent);
  29. color: var(--paper);
  30. padding: 24px 32px;
  31. border-bottom: 4px double var(--paper);
  32. }
  33. header h1 {
  34. margin: 0;
  35. font-size: 26px;
  36. letter-spacing: 1px;
  37. text-transform: uppercase;
  38. }
  39. header p { margin: 8px 0 0; font-style: italic; opacity: 0.9; }
  40. main {
  41. max-width: 1100px;
  42. margin: 0 auto;
  43. padding: 24px;
  44. display: grid;
  45. gap: 24px;
  46. }
  47. section {
  48. background: var(--paper);
  49. border: 1px solid var(--border);
  50. padding: 20px 24px;
  51. box-shadow: 2px 2px 0 var(--border);
  52. }
  53. h2 {
  54. margin-top: 0;
  55. color: var(--accent);
  56. font-size: 20px;
  57. border-bottom: 2px solid var(--accent);
  58. padding-bottom: 6px;
  59. }
  60. label { display: block; margin: 14px 0 6px; font-weight: bold; font-size: 14px; }
  61. .hint { font-size: 12px; font-style: italic; color: #6b5d3f; margin: 4px 0 0; }
  62. textarea, input[type="number"] {
  63. width: 100%;
  64. font-family: 'Courier New', monospace;
  65. font-size: 13px;
  66. padding: 10px;
  67. border: 1px solid var(--border);
  68. background: #fffef9;
  69. color: var(--ink);
  70. resize: vertical;
  71. }
  72. textarea { min-height: 70px; }
  73. input[type="number"] { max-width: 140px; }
  74. button {
  75. margin-top: 16px;
  76. background: var(--accent);
  77. color: var(--paper);
  78. border: none;
  79. padding: 12px 28px;
  80. font-family: Georgia, serif;
  81. font-size: 16px;
  82. font-weight: bold;
  83. letter-spacing: 1px;
  84. text-transform: uppercase;
  85. cursor: pointer;
  86. transition: background 0.2s;
  87. }
  88. button:hover { background: var(--accent-light); }
  89. button:disabled { opacity: 0.5; cursor: not-allowed; }
  90. #summary {
  91. padding: 12px 16px;
  92. margin-bottom: 12px;
  93. border-left: 6px solid var(--border);
  94. font-weight: bold;
  95. }
  96. #summary.ok { border-color: var(--ok); background: #e8f5e9; color: var(--ok); }
  97. #summary.fail { border-color: var(--fail); background: #ffebee; color: var(--fail); }
  98. table {
  99. width: 100%;
  100. border-collapse: collapse;
  101. font-family: 'Courier New', monospace;
  102. font-size: 12px;
  103. margin-top: 12px;
  104. }
  105. th, td { padding: 6px 10px; border: 1px solid var(--border); text-align: right; }
  106. th {
  107. background: var(--accent);
  108. color: var(--paper);
  109. text-align: center;
  110. font-family: Georgia, serif;
  111. font-weight: bold;
  112. }
  113. td:first-child, th:first-child { text-align: center; }
  114. tr.ok td { background: #f1f8e9; }
  115. tr.fail td { background: #ffebee; }
  116. .chart-wrap {
  117. margin-top: 16px;
  118. background: #fffef9;
  119. border: 1px solid var(--border);
  120. padding: 10px;
  121. }
  122. canvas { width: 100%; height: 320px; display: block; }
  123. .actions { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 16px; }
  124. .actions button { margin-top: 0; }
  125. .actions button.secondary {
  126. background: transparent;
  127. color: var(--accent);
  128. border: 2px solid var(--accent);
  129. }
  130. .actions button.secondary:hover { background: var(--accent); color: var(--paper); }
  131. details {
  132. margin-top: 12px;
  133. border: 1px dashed var(--border);
  134. padding: 12px;
  135. background: #fffef9;
  136. }
  137. details summary { cursor: pointer; font-weight: bold; color: var(--accent); }
  138. details p, details li { font-size: 14px; }
  139. code { background: #f0e9d4; padding: 2px 5px; font-size: 12px; }
  140. pre {
  141. background: #f0e9d4;
  142. padding: 8px;
  143. font-size: 12px;
  144. overflow-x: auto;
  145. margin: 6px 0;
  146. }
  147. </style>
  148. </head>
  149. <body>
  150. <header>
  151. <h1>Диводел — Испытательный стенд</h1>
  152. <p>Автоматическая проверка физического ядра PWA-моделей</p>
  153. </header>
  154. <main>
  155. <section>
  156. <h2>1. Конфигурация</h2>
  157. <details>
  158. <summary>Как пользоваться стендом</summary>
  159. <ol>
  160. <li>Задайте переменные в формате <code>имя:мин:макс:шаг</code> (одна на строку).</li>
  161. <li>Впишите <b>эталонную формулу</b> — закон физики, которому должна подчиняться модель.</li>
  162. <li>Вставьте <b>свою формулу</b> — функцию вычисления, которую использует ваша модель.</li>
  163. <li>Задайте допустимое отклонение в процентах.</li>
  164. <li>Нажмите «Запустить проверку». Стенд переберёт все комбинации параметров и покажет расхождения.</li>
  165. </ol>
  166. <p>Переменные доступны в обеих формулах по имени. Используйте стандартные функции <code>Math.log10</code>, <code>Math.pow</code>, <code>Math.sqrt</code> и т.д.</p>
  167. <p><b>Пример 1.</b> Закон обратных квадратов для уровня звука:</p>
  168. <pre>эталон: 60 - 20 * Math.log10(distance) - noise
  169. ваша: 60 - 10 * Math.log10(distance) - noise // ошибка: 10 вместо 20</pre>
  170. <p><b>Пример 2.</b> АЧХ ФНЧ первого порядка:</p>
  171. <pre>эталон: 20 * Math.log10(1 / Math.sqrt(1 + Math.pow(f / fc, 2)))
  172. ваша: 20 * Math.log10(1 / (1 + f / fc)) // ошибка: без квадрата и корня</pre>
  173. <p><b>Важно:</b> стенд проверяет физику (формулы), а не интерфейс. Кнопки, сцены, офлайн-режим и манифест PWA проверяются вручную по чек-листу приёмки.</p>
  174. </details>
  175. <label>Переменные (имя:мин:макс:шаг):</label>
  176. <textarea id="vars">distance:1:10:1
  177. noise:0:60:20</textarea>
  178. <p class="hint">Каждая строка — одна переменная. Стенд переберёт все комбинации.</p>
  179. <label>Эталонная формула (по учебнику):</label>
  180. <textarea id="ref">60 - 20 * Math.log10(distance) - noise</textarea>
  181. <label>Ваша формула (из модели):</label>
  182. <textarea id="mine">60 - 20 * Math.log10(distance) - noise</textarea>
  183. <label>Допустимое отклонение (%):</label>
  184. <input type="number" id="tol" value="1" min="0" step="0.1">
  185. <button id="run">▶ Запустить проверку</button>
  186. </section>
  187. <section>
  188. <h2>2. Результаты</h2>
  189. <div id="summary">Нажмите «Запустить проверку», чтобы увидеть результат.</div>
  190. <div id="tableWrap"></div>
  191. <div class="chart-wrap">
  192. <canvas id="chart"></canvas>
  193. </div>
  194. <div class="actions">
  195. <button id="export" class="secondary" disabled>Скачать CSV</button>
  196. </div>
  197. </section>
  198. </main>
  199. <script>
  200. (() => {
  201. const $ = (id) => document.getElementById(id);
  202. let lastResults = null;
  203. const RESERVED = new Set(['break','case','catch','class','const','continue','debugger',
  204. 'default','delete','do','else','export','extends','finally','for','function','if',
  205. 'import','in','instanceof','new','return','super','switch','this','throw','try',
  206. 'typeof','var','void','while','with','yield','let','static','enum','await']);
  207. function parseVars(text) {
  208. const lines = text.split('\n').map(s => s.trim()).filter(Boolean);
  209. return lines.map(line => {
  210. const [name, minS, maxS, stepS] = line.split(':').map(s => s.trim());
  211. if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name || '') || RESERVED.has(name)) {
  212. throw new Error(`Неверное имя переменной: "${name || line}"`);
  213. }
  214. const min = parseFloat(minS), max = parseFloat(maxS), step = parseFloat(stepS);
  215. if (isNaN(min) || isNaN(max) || isNaN(step) || step <= 0) {
  216. throw new Error(`Неверный диапазон в "${line}". Ожидается имя:мин:макс:шаг`);
  217. }
  218. if (min > max) throw new Error(`У переменной "${name}" минимум больше максимума`);
  219. return { name, min, max, step };
  220. });
  221. }
  222. function generateGrid(vars) {
  223. if (vars.length === 0) return [{}];
  224. const combos = [];
  225. const recur = (i, acc) => {
  226. if (i === vars.length) { combos.push({ ...acc }); return; }
  227. const v = vars[i];
  228. for (let x = v.min; x <= v.max + 1e-9; x += v.step) {
  229. acc[v.name] = parseFloat(x.toFixed(6));
  230. recur(i + 1, acc);
  231. }
  232. };
  233. recur(0, {});
  234. return combos;
  235. }
  236. function compileExpr(expr, varNames) {
  237. if (!expr.trim()) throw new Error('Пустая формула');
  238. // eslint-disable-next-line no-new-func
  239. return new Function(...varNames, `"use strict"; return (${expr});`);
  240. }
  241. function fmt(n) {
  242. if (!isFinite(n)) return String(n);
  243. if (Math.abs(n) >= 1000 || (Math.abs(n) < 0.01 && n !== 0)) return n.toExponential(3);
  244. return n.toFixed(3);
  245. }
  246. function run() {
  247. const summary = $('summary');
  248. const tableWrap = $('tableWrap');
  249. const exportBtn = $('export');
  250. exportBtn.disabled = true;
  251. lastResults = null;
  252. try {
  253. const vars = parseVars($('vars').value);
  254. const varNames = vars.map(v => v.name);
  255. const refFn = compileExpr($('ref').value, varNames);
  256. const mineFn = compileExpr($('mine').value, varNames);
  257. const tol = parseFloat($('tol').value);
  258. if (isNaN(tol) || tol < 0) throw new Error('Неверное допустимое отклонение');
  259. const grid = generateGrid(vars);
  260. if (grid.length > 5000) {
  261. throw new Error(`Слишком много комбинаций (${grid.length}). Уменьшите диапазоны.`);
  262. }
  263. const results = [];
  264. let passed = 0, failed = 0;
  265. for (const combo of grid) {
  266. const values = varNames.map(n => combo[n]);
  267. let expected, actual;
  268. try { expected = refFn(...values); } catch (e) { expected = NaN; }
  269. try { actual = mineFn(...values); } catch (e) { actual = NaN; }
  270. let status, delta = null;
  271. if (!isFinite(expected) || !isFinite(actual)) {
  272. status = 'fail';
  273. failed++;
  274. } else {
  275. const denom = Math.abs(expected) < 1e-9 ? 1 : Math.abs(expected);
  276. const pct = Math.abs((actual - expected) / denom) * 100;
  277. delta = pct;
  278. if (pct <= tol) { status = 'ok'; passed++; }
  279. else { status = 'fail'; failed++; }
  280. }
  281. results.push({ combo, expected, actual, delta, status });
  282. }
  283. lastResults = { vars, results, tol };
  284. if (failed === 0) {
  285. summary.className = 'ok';
  286. summary.textContent = `✓ Все проверки пройдены: ${passed} из ${grid.length} комбинаций в пределах ±${tol}%.`;
  287. } else {
  288. summary.className = 'fail';
  289. summary.textContent = `✗ Провалено ${failed} из ${grid.length} комбинаций. Расхождения выше ±${tol}%.`;
  290. }
  291. renderTable(results, vars, tol);
  292. drawChart(results);
  293. exportBtn.disabled = false;
  294. } catch (e) {
  295. summary.className = 'fail';
  296. summary.textContent = `Ошибка: ${e.message}`;
  297. tableWrap.innerHTML = '';
  298. const canvas = $('chart');
  299. const ctx = canvas.getContext('2d');
  300. ctx.clearRect(0, 0, canvas.width, canvas.height);
  301. }
  302. }
  303. function renderTable(results, vars, tol) {
  304. const showRows = results.slice(0, 500);
  305. const headCells = vars.map(v => `<th>${v.name}</th>`).join('');
  306. const rows = showRows.map((r, i) => {
  307. const comboCells = vars.map(v => `<td>${r.combo[v.name]}</td>`).join('');
  308. const delta = r.delta === null ? '—' : r.delta.toFixed(2) + '%';
  309. return `<tr class="${r.status}">
  310. <td>${i + 1}</td>
  311. ${comboCells}
  312. <td>${fmt(r.expected)}</td>
  313. <td>${fmt(r.actual)}</td>
  314. <td>${delta}</td>
  315. <td>${r.status === 'ok' ? '✓' : '✗'}</td>
  316. </tr>`;
  317. }).join('');
  318. let footer = '';
  319. if (results.length > 500) {
  320. footer = `<p class="hint">Показаны первые 500 строк из ${results.length}. Полный отчёт — в CSV.</p>`;
  321. }
  322. $('tableWrap').innerHTML = `
  323. <table>
  324. <thead>
  325. <tr>
  326. <th>#</th>
  327. ${headCells}
  328. <th>Эталон</th>
  329. <th>Модель</th>
  330. <th>Δ</th>
  331. <th>Статус</th>
  332. </tr>
  333. </thead>
  334. <tbody>${rows}</tbody>
  335. </table>
  336. ${footer}
  337. `;
  338. }
  339. function drawChart(results) {
  340. const canvas = $('chart');
  341. const dpr = window.devicePixelRatio || 1;
  342. const w = canvas.clientWidth || 800;
  343. const h = canvas.clientHeight || 320;
  344. canvas.width = w * dpr;
  345. canvas.height = h * dpr;
  346. const ctx = canvas.getContext('2d');
  347. ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  348. ctx.clearRect(0, 0, w, h);
  349. const valid = results.filter(r => isFinite(r.expected) && isFinite(r.actual));
  350. if (valid.length === 0) {
  351. ctx.fillStyle = '#8b1a1a';
  352. ctx.font = '14px Georgia';
  353. ctx.textAlign = 'center';
  354. ctx.fillText('Нет валидных данных для графика', w / 2, h / 2);
  355. return;
  356. }
  357. const pad = 48;
  358. const plotW = w - pad * 2;
  359. const plotH = h - pad * 2;
  360. const allVals = valid.flatMap(r => [r.expected, r.actual]);
  361. let minV = Math.min(...allVals), maxV = Math.max(...allVals);
  362. if (minV === maxV) { minV -= 1; maxV += 1; }
  363. const span = maxV - minV;
  364. minV -= span * 0.05;
  365. maxV += span * 0.05;
  366. const sx = v => pad + ((v - minV) / (maxV - minV)) * plotW;
  367. const sy = v => pad + plotH - ((v - minV) / (maxV - minV)) * plotH;
  368. ctx.strokeStyle = '#2a2419';
  369. ctx.lineWidth = 1;
  370. ctx.beginPath();
  371. ctx.moveTo(pad, pad);
  372. ctx.lineTo(pad, pad + plotH);
  373. ctx.lineTo(pad + plotW, pad + plotH);
  374. ctx.stroke();
  375. ctx.strokeStyle = '#d4c9a8';
  376. ctx.setLineDash([5, 5]);
  377. ctx.beginPath();
  378. ctx.moveTo(sx(minV), sy(minV));
  379. ctx.lineTo(sx(maxV), sy(maxV));
  380. ctx.stroke();
  381. ctx.setLineDash([]);
  382. for (const r of valid) {
  383. ctx.fillStyle = r.status === 'ok' ? 'rgba(46,125,50,0.75)' : 'rgba(198,40,40,0.85)';
  384. ctx.beginPath();
  385. ctx.arc(sx(r.expected), sy(r.actual), 3, 0, Math.PI * 2);
  386. ctx.fill();
  387. }
  388. ctx.fillStyle = '#2a2419';
  389. ctx.font = '13px Georgia';
  390. ctx.textAlign = 'center';
  391. ctx.fillText('Эталон (по формуле)', w / 2, h - 10);
  392. ctx.save();
  393. ctx.translate(14, h / 2);
  394. ctx.rotate(-Math.PI / 2);
  395. ctx.fillText('Модель', 0, 0);
  396. ctx.restore();
  397. ctx.font = '11px Courier New';
  398. ctx.textAlign = 'center';
  399. ctx.fillText(fmt(minV), pad, pad + plotH + 16);
  400. ctx.fillText(fmt(maxV), pad + plotW, pad + plotH + 16);
  401. ctx.textAlign = 'right';
  402. ctx.fillText(fmt(minV), pad - 6, pad + plotH + 4);
  403. ctx.fillText(fmt(maxV), pad - 6, pad + 4);
  404. ctx.textAlign = 'left';
  405. ctx.font = '12px Georgia';
  406. ctx.fillStyle = 'rgba(46,125,50,0.9)';
  407. ctx.fillRect(w - 150, 12, 10, 10);
  408. ctx.fillStyle = '#2a2419';
  409. ctx.fillText('В допуске', w - 134, 21);
  410. ctx.fillStyle = 'rgba(198,40,40,0.9)';
  411. ctx.fillRect(w - 150, 28, 10, 10);
  412. ctx.fillStyle = '#2a2419';
  413. ctx.fillText('Вне допуска', w - 134, 37);
  414. }
  415. function exportCsv() {
  416. if (!lastResults) return;
  417. const { vars, results } = lastResults;
  418. const header = ['#', ...vars.map(v => v.name), 'Эталон', 'Модель', 'Отклонение %', 'Статус'];
  419. const lines = [header.join(';')];
  420. results.forEach((r, i) => {
  421. lines.push([
  422. i + 1,
  423. ...vars.map(v => r.combo[v.name]),
  424. r.expected,
  425. r.actual,
  426. r.delta === null ? '' : r.delta.toFixed(3),
  427. r.status === 'ok' ? 'OK' : 'FAIL'
  428. ].join(';'));
  429. });
  430. const blob = new Blob(['\ufeff' + lines.join('\n')], { type: 'text/csv;charset=utf-8' });
  431. const a = document.createElement('a');
  432. a.href = URL.createObjectURL(blob);
  433. a.download = `divodel-test-${new Date().toISOString().slice(0,10)}.csv`;
  434. document.body.appendChild(a);
  435. a.click();
  436. a.remove();
  437. URL.revokeObjectURL(a.href);
  438. }
  439. $('run').addEventListener('click', run);
  440. $('export').addEventListener('click', exportCsv);
  441. window.addEventListener('resize', () => {
  442. if (lastResults) drawChart(lastResults.results);
  443. });
  444. })();
  445. </script>
  446. </body>
  447. </html>