ypv 2 هفته پیش
والد
کامیت
e6bac22337
3فایلهای تغییر یافته به همراه499 افزوده شده و 3 حذف شده
  1. 1 1
      requirements.md
  2. 7 2
      template/specification_template.md
  3. 491 0
      tester.html

+ 1 - 1
requirements.md

@@ -24,7 +24,7 @@
 
 ## 3. Техническое задание (ТЗ)
 
-1. **Сценарий:** Оформляется в отдельном файле (`scenario.md` или `ТЗ.md`). 
+1. **Сценарий:** Оформляется в отдельном разделе Листа спецификации. 
    - Сценарий состоит из **«Сцен»**. Одна «Сцена» — это один этап/экран приложения.
    - В каждой «Сцене» должны быть описаны:
      - элементы интерфейса, из которых она состоит;

+ 7 - 2
template/specification_template.md

@@ -46,7 +46,12 @@
 
 ---
 
-## 4. Журнал верификации и отладки (Борьба с галлюцинациями ИИ)
+
+## 4. Сценарий
+*Заполняется студентом. Это показывает честность модели и понимание ее ограничений.*
+
+
+## 5. Журнал верификации и отладки (Борьба с галлюцинациями ИИ)
 *Заполняется ПОСЛЕ генерации кода. Это доказательство того, что студент проверял работу ИИ. Каждое исправление должно быть зафиксировано в системе контроля версий.*
 
 | № | Что попросили у ИИ | Что сгенерировал ИИ | Ошибка (галлюцинация) | Как исправили | Имя коммита (Git hash / сообщение) |
@@ -59,7 +64,7 @@
 
 ---
 
-## 5. Критерии приёмки (Чек-лист преподавателя)
+## 6. Критерии приёмки (Чек-лист преподавателя)
 *Работа не принимается, если хотя бы один пункт не выполнен.*
 
 - [ ] Код модели физически корректен (соответствует формулам из п.1).

+ 491 - 0
tester.html

@@ -0,0 +1,491 @@
+<!DOCTYPE html>
+<html lang="ru">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>Диводел — Испытательный стенд</title>
+<style>
+  :root {
+    --bg: #f5f0e1;
+    --paper: #fdfaf2;
+    --ink: #2a2419;
+    --accent: #8b1a1a;
+    --accent-light: #b84242;
+    --ok: #2e7d32;
+    --fail: #c62828;
+    --warn: #ef6c00;
+    --border: #d4c9a8;
+  }
+  * { box-sizing: border-box; }
+  body {
+    margin: 0;
+    font-family: Georgia, 'Times New Roman', serif;
+    background: var(--bg);
+    color: var(--ink);
+    line-height: 1.5;
+  }
+  header {
+    background: var(--accent);
+    color: var(--paper);
+    padding: 24px 32px;
+    border-bottom: 4px double var(--paper);
+  }
+  header h1 {
+    margin: 0;
+    font-size: 26px;
+    letter-spacing: 1px;
+    text-transform: uppercase;
+  }
+  header p { margin: 8px 0 0; font-style: italic; opacity: 0.9; }
+  main {
+    max-width: 1100px;
+    margin: 0 auto;
+    padding: 24px;
+    display: grid;
+    gap: 24px;
+  }
+  section {
+    background: var(--paper);
+    border: 1px solid var(--border);
+    padding: 20px 24px;
+    box-shadow: 2px 2px 0 var(--border);
+  }
+  h2 {
+    margin-top: 0;
+    color: var(--accent);
+    font-size: 20px;
+    border-bottom: 2px solid var(--accent);
+    padding-bottom: 6px;
+  }
+  label { display: block; margin: 14px 0 6px; font-weight: bold; font-size: 14px; }
+  .hint { font-size: 12px; font-style: italic; color: #6b5d3f; margin: 4px 0 0; }
+  textarea, input[type="number"] {
+    width: 100%;
+    font-family: 'Courier New', monospace;
+    font-size: 13px;
+    padding: 10px;
+    border: 1px solid var(--border);
+    background: #fffef9;
+    color: var(--ink);
+    resize: vertical;
+  }
+  textarea { min-height: 70px; }
+  input[type="number"] { max-width: 140px; }
+  button {
+    margin-top: 16px;
+    background: var(--accent);
+    color: var(--paper);
+    border: none;
+    padding: 12px 28px;
+    font-family: Georgia, serif;
+    font-size: 16px;
+    font-weight: bold;
+    letter-spacing: 1px;
+    text-transform: uppercase;
+    cursor: pointer;
+    transition: background 0.2s;
+  }
+  button:hover { background: var(--accent-light); }
+  button:disabled { opacity: 0.5; cursor: not-allowed; }
+  #summary {
+    padding: 12px 16px;
+    margin-bottom: 12px;
+    border-left: 6px solid var(--border);
+    font-weight: bold;
+  }
+  #summary.ok { border-color: var(--ok); background: #e8f5e9; color: var(--ok); }
+  #summary.fail { border-color: var(--fail); background: #ffebee; color: var(--fail); }
+  table {
+    width: 100%;
+    border-collapse: collapse;
+    font-family: 'Courier New', monospace;
+    font-size: 12px;
+    margin-top: 12px;
+  }
+  th, td { padding: 6px 10px; border: 1px solid var(--border); text-align: right; }
+  th {
+    background: var(--accent);
+    color: var(--paper);
+    text-align: center;
+    font-family: Georgia, serif;
+    font-weight: bold;
+  }
+  td:first-child, th:first-child { text-align: center; }
+  tr.ok td { background: #f1f8e9; }
+  tr.fail td { background: #ffebee; }
+  .chart-wrap {
+    margin-top: 16px;
+    background: #fffef9;
+    border: 1px solid var(--border);
+    padding: 10px;
+  }
+  canvas { width: 100%; height: 320px; display: block; }
+  .actions { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 16px; }
+  .actions button { margin-top: 0; }
+  .actions button.secondary {
+    background: transparent;
+    color: var(--accent);
+    border: 2px solid var(--accent);
+  }
+  .actions button.secondary:hover { background: var(--accent); color: var(--paper); }
+  details {
+    margin-top: 12px;
+    border: 1px dashed var(--border);
+    padding: 12px;
+    background: #fffef9;
+  }
+  details summary { cursor: pointer; font-weight: bold; color: var(--accent); }
+  details p, details li { font-size: 14px; }
+  code { background: #f0e9d4; padding: 2px 5px; font-size: 12px; }
+  pre {
+    background: #f0e9d4;
+    padding: 8px;
+    font-size: 12px;
+    overflow-x: auto;
+    margin: 6px 0;
+  }
+</style>
+</head>
+<body>
+
+<header>
+  <h1>Диводел — Испытательный стенд</h1>
+  <p>Автоматическая проверка физического ядра PWA-моделей</p>
+</header>
+
+<main>
+
+<section>
+  <h2>1. Конфигурация</h2>
+
+  <details>
+    <summary>Как пользоваться стендом</summary>
+    <ol>
+      <li>Задайте переменные в формате <code>имя:мин:макс:шаг</code> (одна на строку).</li>
+      <li>Впишите <b>эталонную формулу</b> — закон физики, которому должна подчиняться модель.</li>
+      <li>Вставьте <b>свою формулу</b> — функцию вычисления, которую использует ваша модель.</li>
+      <li>Задайте допустимое отклонение в процентах.</li>
+      <li>Нажмите «Запустить проверку». Стенд переберёт все комбинации параметров и покажет расхождения.</li>
+    </ol>
+    <p>Переменные доступны в обеих формулах по имени. Используйте стандартные функции <code>Math.log10</code>, <code>Math.pow</code>, <code>Math.sqrt</code> и т.д.</p>
+    <p><b>Пример 1.</b> Закон обратных квадратов для уровня звука:</p>
+    <pre>эталон: 60 - 20 * Math.log10(distance) - noise
+ваша:   60 - 10 * Math.log10(distance) - noise   // ошибка: 10 вместо 20</pre>
+    <p><b>Пример 2.</b> АЧХ ФНЧ первого порядка:</p>
+    <pre>эталон: 20 * Math.log10(1 / Math.sqrt(1 + Math.pow(f / fc, 2)))
+ваша:   20 * Math.log10(1 / (1 + f / fc))   // ошибка: без квадрата и корня</pre>
+    <p><b>Важно:</b> стенд проверяет физику (формулы), а не интерфейс. Кнопки, сцены, офлайн-режим и манифест PWA проверяются вручную по чек-листу приёмки.</p>
+  </details>
+
+  <label>Переменные (имя:мин:макс:шаг):</label>
+  <textarea id="vars">distance:1:10:1
+noise:0:60:20</textarea>
+  <p class="hint">Каждая строка — одна переменная. Стенд переберёт все комбинации.</p>
+
+  <label>Эталонная формула (по учебнику):</label>
+  <textarea id="ref">60 - 20 * Math.log10(distance) - noise</textarea>
+
+  <label>Ваша формула (из модели):</label>
+  <textarea id="mine">60 - 20 * Math.log10(distance) - noise</textarea>
+
+  <label>Допустимое отклонение (%):</label>
+  <input type="number" id="tol" value="1" min="0" step="0.1">
+
+  <button id="run">▶ Запустить проверку</button>
+</section>
+
+<section>
+  <h2>2. Результаты</h2>
+  <div id="summary">Нажмите «Запустить проверку», чтобы увидеть результат.</div>
+  <div id="tableWrap"></div>
+  <div class="chart-wrap">
+    <canvas id="chart"></canvas>
+  </div>
+  <div class="actions">
+    <button id="export" class="secondary" disabled>Скачать CSV</button>
+  </div>
+</section>
+
+</main>
+
+<script>
+(() => {
+  const $ = (id) => document.getElementById(id);
+  let lastResults = null;
+
+  const RESERVED = new Set(['break','case','catch','class','const','continue','debugger',
+    'default','delete','do','else','export','extends','finally','for','function','if',
+    'import','in','instanceof','new','return','super','switch','this','throw','try',
+    'typeof','var','void','while','with','yield','let','static','enum','await']);
+
+  function parseVars(text) {
+    const lines = text.split('\n').map(s => s.trim()).filter(Boolean);
+    return lines.map(line => {
+      const [name, minS, maxS, stepS] = line.split(':').map(s => s.trim());
+      if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name || '') || RESERVED.has(name)) {
+        throw new Error(`Неверное имя переменной: "${name || line}"`);
+      }
+      const min = parseFloat(minS), max = parseFloat(maxS), step = parseFloat(stepS);
+      if (isNaN(min) || isNaN(max) || isNaN(step) || step <= 0) {
+        throw new Error(`Неверный диапазон в "${line}". Ожидается имя:мин:макс:шаг`);
+      }
+      if (min > max) throw new Error(`У переменной "${name}" минимум больше максимума`);
+      return { name, min, max, step };
+    });
+  }
+
+  function generateGrid(vars) {
+    if (vars.length === 0) return [{}];
+    const combos = [];
+    const recur = (i, acc) => {
+      if (i === vars.length) { combos.push({ ...acc }); return; }
+      const v = vars[i];
+      for (let x = v.min; x <= v.max + 1e-9; x += v.step) {
+        acc[v.name] = parseFloat(x.toFixed(6));
+        recur(i + 1, acc);
+      }
+    };
+    recur(0, {});
+    return combos;
+  }
+
+  function compileExpr(expr, varNames) {
+    if (!expr.trim()) throw new Error('Пустая формула');
+    // eslint-disable-next-line no-new-func
+    return new Function(...varNames, `"use strict"; return (${expr});`);
+  }
+
+  function fmt(n) {
+    if (!isFinite(n)) return String(n);
+    if (Math.abs(n) >= 1000 || (Math.abs(n) < 0.01 && n !== 0)) return n.toExponential(3);
+    return n.toFixed(3);
+  }
+
+  function run() {
+    const summary = $('summary');
+    const tableWrap = $('tableWrap');
+    const exportBtn = $('export');
+    exportBtn.disabled = true;
+    lastResults = null;
+
+    try {
+      const vars = parseVars($('vars').value);
+      const varNames = vars.map(v => v.name);
+      const refFn = compileExpr($('ref').value, varNames);
+      const mineFn = compileExpr($('mine').value, varNames);
+      const tol = parseFloat($('tol').value);
+      if (isNaN(tol) || tol < 0) throw new Error('Неверное допустимое отклонение');
+
+      const grid = generateGrid(vars);
+      if (grid.length > 5000) {
+        throw new Error(`Слишком много комбинаций (${grid.length}). Уменьшите диапазоны.`);
+      }
+
+      const results = [];
+      let passed = 0, failed = 0;
+
+      for (const combo of grid) {
+        const values = varNames.map(n => combo[n]);
+        let expected, actual;
+        try { expected = refFn(...values); } catch (e) { expected = NaN; }
+        try { actual = mineFn(...values); } catch (e) { actual = NaN; }
+
+        let status, delta = null;
+        if (!isFinite(expected) || !isFinite(actual)) {
+          status = 'fail';
+          failed++;
+        } else {
+          const denom = Math.abs(expected) < 1e-9 ? 1 : Math.abs(expected);
+          const pct = Math.abs((actual - expected) / denom) * 100;
+          delta = pct;
+          if (pct <= tol) { status = 'ok'; passed++; }
+          else { status = 'fail'; failed++; }
+        }
+        results.push({ combo, expected, actual, delta, status });
+      }
+
+      lastResults = { vars, results, tol };
+
+      if (failed === 0) {
+        summary.className = 'ok';
+        summary.textContent = `✓ Все проверки пройдены: ${passed} из ${grid.length} комбинаций в пределах ±${tol}%.`;
+      } else {
+        summary.className = 'fail';
+        summary.textContent = `✗ Провалено ${failed} из ${grid.length} комбинаций. Расхождения выше ±${tol}%.`;
+      }
+
+      renderTable(results, vars, tol);
+      drawChart(results);
+      exportBtn.disabled = false;
+    } catch (e) {
+      summary.className = 'fail';
+      summary.textContent = `Ошибка: ${e.message}`;
+      tableWrap.innerHTML = '';
+      const canvas = $('chart');
+      const ctx = canvas.getContext('2d');
+      ctx.clearRect(0, 0, canvas.width, canvas.height);
+    }
+  }
+
+  function renderTable(results, vars, tol) {
+    const showRows = results.slice(0, 500);
+    const headCells = vars.map(v => `<th>${v.name}</th>`).join('');
+    const rows = showRows.map((r, i) => {
+      const comboCells = vars.map(v => `<td>${r.combo[v.name]}</td>`).join('');
+      const delta = r.delta === null ? '—' : r.delta.toFixed(2) + '%';
+      return `<tr class="${r.status}">
+        <td>${i + 1}</td>
+        ${comboCells}
+        <td>${fmt(r.expected)}</td>
+        <td>${fmt(r.actual)}</td>
+        <td>${delta}</td>
+        <td>${r.status === 'ok' ? '✓' : '✗'}</td>
+      </tr>`;
+    }).join('');
+
+    let footer = '';
+    if (results.length > 500) {
+      footer = `<p class="hint">Показаны первые 500 строк из ${results.length}. Полный отчёт — в CSV.</p>`;
+    }
+
+    $('tableWrap').innerHTML = `
+      <table>
+        <thead>
+          <tr>
+            <th>#</th>
+            ${headCells}
+            <th>Эталон</th>
+            <th>Модель</th>
+            <th>Δ</th>
+            <th>Статус</th>
+          </tr>
+        </thead>
+        <tbody>${rows}</tbody>
+      </table>
+      ${footer}
+    `;
+  }
+
+  function drawChart(results) {
+    const canvas = $('chart');
+    const dpr = window.devicePixelRatio || 1;
+    const w = canvas.clientWidth || 800;
+    const h = canvas.clientHeight || 320;
+    canvas.width = w * dpr;
+    canvas.height = h * dpr;
+    const ctx = canvas.getContext('2d');
+    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+    ctx.clearRect(0, 0, w, h);
+
+    const valid = results.filter(r => isFinite(r.expected) && isFinite(r.actual));
+    if (valid.length === 0) {
+      ctx.fillStyle = '#8b1a1a';
+      ctx.font = '14px Georgia';
+      ctx.textAlign = 'center';
+      ctx.fillText('Нет валидных данных для графика', w / 2, h / 2);
+      return;
+    }
+
+    const pad = 48;
+    const plotW = w - pad * 2;
+    const plotH = h - pad * 2;
+
+    const allVals = valid.flatMap(r => [r.expected, r.actual]);
+    let minV = Math.min(...allVals), maxV = Math.max(...allVals);
+    if (minV === maxV) { minV -= 1; maxV += 1; }
+    const span = maxV - minV;
+    minV -= span * 0.05;
+    maxV += span * 0.05;
+
+    const sx = v => pad + ((v - minV) / (maxV - minV)) * plotW;
+    const sy = v => pad + plotH - ((v - minV) / (maxV - minV)) * plotH;
+
+    ctx.strokeStyle = '#2a2419';
+    ctx.lineWidth = 1;
+    ctx.beginPath();
+    ctx.moveTo(pad, pad);
+    ctx.lineTo(pad, pad + plotH);
+    ctx.lineTo(pad + plotW, pad + plotH);
+    ctx.stroke();
+
+    ctx.strokeStyle = '#d4c9a8';
+    ctx.setLineDash([5, 5]);
+    ctx.beginPath();
+    ctx.moveTo(sx(minV), sy(minV));
+    ctx.lineTo(sx(maxV), sy(maxV));
+    ctx.stroke();
+    ctx.setLineDash([]);
+
+    for (const r of valid) {
+      ctx.fillStyle = r.status === 'ok' ? 'rgba(46,125,50,0.75)' : 'rgba(198,40,40,0.85)';
+      ctx.beginPath();
+      ctx.arc(sx(r.expected), sy(r.actual), 3, 0, Math.PI * 2);
+      ctx.fill();
+    }
+
+    ctx.fillStyle = '#2a2419';
+    ctx.font = '13px Georgia';
+    ctx.textAlign = 'center';
+    ctx.fillText('Эталон (по формуле)', w / 2, h - 10);
+    ctx.save();
+    ctx.translate(14, h / 2);
+    ctx.rotate(-Math.PI / 2);
+    ctx.fillText('Модель', 0, 0);
+    ctx.restore();
+
+    ctx.font = '11px Courier New';
+    ctx.textAlign = 'center';
+    ctx.fillText(fmt(minV), pad, pad + plotH + 16);
+    ctx.fillText(fmt(maxV), pad + plotW, pad + plotH + 16);
+    ctx.textAlign = 'right';
+    ctx.fillText(fmt(minV), pad - 6, pad + plotH + 4);
+    ctx.fillText(fmt(maxV), pad - 6, pad + 4);
+
+    ctx.textAlign = 'left';
+    ctx.font = '12px Georgia';
+    ctx.fillStyle = 'rgba(46,125,50,0.9)';
+    ctx.fillRect(w - 150, 12, 10, 10);
+    ctx.fillStyle = '#2a2419';
+    ctx.fillText('В допуске', w - 134, 21);
+    ctx.fillStyle = 'rgba(198,40,40,0.9)';
+    ctx.fillRect(w - 150, 28, 10, 10);
+    ctx.fillStyle = '#2a2419';
+    ctx.fillText('Вне допуска', w - 134, 37);
+  }
+
+  function exportCsv() {
+    if (!lastResults) return;
+    const { vars, results } = lastResults;
+    const header = ['#', ...vars.map(v => v.name), 'Эталон', 'Модель', 'Отклонение %', 'Статус'];
+    const lines = [header.join(';')];
+    results.forEach((r, i) => {
+      lines.push([
+        i + 1,
+        ...vars.map(v => r.combo[v.name]),
+        r.expected,
+        r.actual,
+        r.delta === null ? '' : r.delta.toFixed(3),
+        r.status === 'ok' ? 'OK' : 'FAIL'
+      ].join(';'));
+    });
+    const blob = new Blob(['\ufeff' + lines.join('\n')], { type: 'text/csv;charset=utf-8' });
+    const a = document.createElement('a');
+    a.href = URL.createObjectURL(blob);
+    a.download = `divodel-test-${new Date().toISOString().slice(0,10)}.csv`;
+    document.body.appendChild(a);
+    a.click();
+    a.remove();
+    URL.revokeObjectURL(a.href);
+  }
+
+  $('run').addEventListener('click', run);
+  $('export').addEventListener('click', exportCsv);
+
+  window.addEventListener('resize', () => {
+    if (lastResults) drawChart(lastResults.results);
+  });
+})();
+</script>
+
+</body>
+</html>