merge: feature/lab-content-engine → master

Контент-движок лаборатории (фазы 0-5): LabRegistry, data-driven регистрация,
вынос тел в labs-bodies.html, ленивая загрузка кода, БД-каталог lab_sims + API +
админка, курикулумные связи lab_sim_links + двусторонняя навигация.
Плюс накопленная работа параллельных сессий (chemistry-8, phys7, biochem, optics).

Разрешение конфликтов: frontend/lab.html — версия feature (контент-движок);
opticsbench.js / seed_biochem_challenges.js / BIOCHEM_UPGRADE.md /
biochem-pathways-plan.md — версия master (более свежая работа парал. сессий).

Тесты: 160, 157 pass, 3 fail (pre-existing baseline auth.test.js).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-05-30 17:53:58 +03:00
52 changed files with 13088 additions and 4605 deletions
+305
View File
@@ -0,0 +1,305 @@
/* gen_chem8_skeletons.js — генерирует каркасы 7 глав «Химия 8» (Phase 0).
* Запуск: node backend/scripts/gen_chem8_skeletons.js
* Выход: frontend/textbooks/chemistry_8_intro.html, _ch1.html ... _ch6.html
*
* Каркас = валидная брендированная страница: header (водяной знак), hero,
* оглавление § (read-only), баннер «в разработке», ссылка назад в хаб, тема.
* Полный интерактивный SPA-контент каждой главы добавляется в Phase 1–6
* (файлы перезаписываются), пока скелет обеспечивает навигацию и структуру.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const OUT = path.join(__dirname, '..', '..', 'frontend', 'textbooks');
const P = (t, n) => ({ t, n }); // параграф
const NOTE = (note) => ({ note }); // лаб. опыт / практическая работа
const CHAPTERS = [
{
file: 'chemistry_8_intro.html', slug: 'chemistry-8-intro',
kicker: 'Вводный раздел', title: 'Количественные понятия в химии',
range: '§ 19', wm: 'mol',
color: { p:'#d97706', d:'#b45309', l:'#fbbf24', soft:'#fef3c7', bgd:'#1c1410', cardd:'#271c14', textd:'#fef3c7' },
items: [
P('§ 1', 'Атомы. Химические элементы. Относительная атомная масса'),
P('§ 2', 'Молекулы. Простые и сложные вещества. Химические формулы. Относительная молекулярная масса'),
P('§ 3', 'Химическое количество вещества'),
P('§ 4', 'Моль — единица химического количества вещества. Постоянная Авогадро'),
P('§ 5', 'Молярная масса. Молярный объём газов'),
P('§ 6', 'Вычисление химического количества вещества по его массе и массы вещества по его химическому количеству'),
P('§ 7', 'Вычисление химического количества газа по его объёму и объёма газа по его химическому количеству'),
NOTE('Практическая работа 1. Химическое количество вещества'),
P('§ 8', 'Химические реакции'),
P('§ 9', 'Количественные расчёты по уравнениям химических реакций')
]
},
{
file: 'chemistry_8_ch1.html', slug: 'chemistry-8-ch1',
kicker: 'Глава 1', title: 'Важнейшие классы неорганических соединений',
range: '§ 1023', wm: 'OH',
color: { p:'#0d9488', d:'#0f766e', l:'#14b8a6', soft:'#ccfbf1', bgd:'#0c1a18', cardd:'#102825', textd:'#ccfbf1' },
items: [
P('§ 10', 'Оксиды. Состав и классификация оксидов'),
P('§ 11', 'Химические свойства оксидов'),
P('§ 12', 'Получение и применение оксидов'),
P('§ 13', 'Кислоты. Состав и классификация кислот'),
P('§ 14', 'Химические свойства кислот'),
P('§ 15', 'Получение и применение кислот'),
P('§ 16', 'Основания'),
P('§ 17', 'Химические свойства оснований'),
P('§ 18', 'Получение и применение оснований'),
NOTE('Лабораторный опыт 1. Получение нерастворимого основания'),
NOTE('Практическая работа 2. Изучение реакции нейтрализации'),
P('§ 19', 'Соли. Состав и классификация солей'),
P('§ 20', 'Химические свойства солей'),
NOTE('Лабораторный опыт 2. Взаимодействие растворов солей с металлами'),
P('§ 21', 'Получение и применение солей'),
P('§ 22', 'Взаимосвязь между классами основных неорганических веществ'),
NOTE('Практическая работа 3. Решение экспериментальных задач'),
P('§ 23', 'Решение расчётных задач по теме «Основные классы неорганических соединений»')
]
},
{
file: 'chemistry_8_ch2.html', slug: 'chemistry-8-ch2',
kicker: 'Глава 2', title: 'Периодический закон и периодическая система химических элементов',
range: '§ 2428', wm: '№',
color: { p:'#4f46e5', d:'#4338ca', l:'#818cf8', soft:'#e0e7ff', bgd:'#12122b', cardd:'#1b1b3a', textd:'#e0e7ff' },
items: [
P('§ 24', 'Систематизация химических элементов'),
P('§ 25', 'Понятие об амфотерности'),
NOTE('Лабораторный опыт 3. Получение гидроксида цинка и изучение его амфотерных свойств'),
P('§ 26', 'Естественные семейства элементов'),
P('§ 27', 'Периодический закон Д. И. Менделеева'),
P('§ 28', 'Периодическая система химических элементов')
]
},
{
file: 'chemistry_8_ch3.html', slug: 'chemistry-8-ch3',
kicker: 'Глава 3', title: 'Строение атома и периодичность изменения свойств',
range: '§ 2935', wm: 'e',
color: { p:'#2563eb', d:'#1d4ed8', l:'#60a5fa', soft:'#dbeafe', bgd:'#0a1428', cardd:'#102137', textd:'#dbeafe' },
items: [
P('§ 29', 'Строение атома. Атомный номер химического элемента'),
P('§ 30', 'Массовое число атома. Нуклиды'),
P('§ 31', 'Изотопы. Явление радиоактивности'),
P('§ 32', 'Состояние электронов в атоме. Электронное облако. Атомная орбиталь'),
P('§ 33', 'Строение электронных оболочек атомов'),
P('§ 34', 'Периодичность изменения свойств атомов химических элементов'),
P('§ 35', 'Характеристика химического элемента по его положению в периодической системе')
]
},
{
file: 'chemistry_8_ch4.html', slug: 'chemistry-8-ch4',
kicker: 'Глава 4', title: 'Химическая связь',
range: '§ 3641', wm: 'H₂O',
color: { p:'#059669', d:'#047857', l:'#34d399', soft:'#d1fae5', bgd:'#0a1a12', cardd:'#10271c', textd:'#d1fae5' },
items: [
P('§ 36', 'Природа химической связи'),
P('§ 37', 'Ковалентная связь'),
P('§ 38', 'Неполярная и полярная ковалентная связь. Электроотрицательность'),
NOTE('Лабораторный опыт 4. Составление моделей молекул'),
P('§ 39', 'Ионная связь'),
P('§ 40', 'Металлическая связь. Межмолекулярное взаимодействие'),
P('§ 41', 'Кристаллическое состояние вещества')
]
},
{
file: 'chemistry_8_ch5.html', slug: 'chemistry-8-ch5',
kicker: 'Глава 5', title: 'Окислительно-восстановительные реакции',
range: '§ 4245', wm: 'O₂',
color: { p:'#ea580c', d:'#c2410c', l:'#fb923c', soft:'#ffedd5', bgd:'#1c1208', cardd:'#2a1c10', textd:'#ffedd5' },
items: [
P('§ 42', 'Степень окисления'),
P('§ 43', 'Процессы окисления и восстановления'),
P('§ 44', 'Окислительно-восстановительные реакции'),
P('§ 45', 'Окислительно-восстановительные реакции вокруг нас')
]
},
{
file: 'chemistry_8_ch6.html', slug: 'chemistry-8-ch6',
kicker: 'Глава 6', title: 'Растворы',
range: '§ 4652', wm: 'aq',
color: { p:'#0891b2', d:'#0e7490', l:'#22d3ee', soft:'#cffafe', bgd:'#08191c', cardd:'#10282d', textd:'#cffafe' },
items: [
P('§ 46', 'Смеси веществ'),
P('§ 47', 'Растворение веществ в воде'),
P('§ 48', 'Характеристики растворимости веществ'),
P('§ 49', 'Качественные характеристики состава растворов'),
P('§ 50', 'Количественные характеристики растворённых веществ. Массовая доля растворённого вещества'),
P('§ 51', 'Молярная концентрация растворённых веществ'),
NOTE('Практическая работа 4. Приготовление раствора с заданной массовой долей и молярной концентрацией'),
P('§ 52', 'Вода и растворы в жизни и деятельности человека')
]
}
];
function esc(s) {
return String(s).replace(/[&<>]/g, c => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;' }[c]));
}
function outlineHtml(items) {
return items.map(it => {
if (it.note) {
return ' <li class="ol-note"><span class="ol-note-ic">' +
'<svg viewBox="0 0 24 24"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>' +
'</span><span>' + esc(it.note) + '</span></li>';
}
return ' <li class="ol-para"><span class="ol-num">' + esc(it.t) + '</span><span class="ol-name">' + esc(it.n) + '</span></li>';
}).join('\n');
}
function pageHtml(ch) {
const c = ch.color;
const wmHeader = ch.kicker.toUpperCase();
return `<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Химия 8 · ${esc(ch.kicker)} · «${esc(ch.title)}»</title>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800;900&family=Inter:wght@400;500;600;700&family=Unbounded:wght@700;800;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
<script src="/js/api.js" defer></script>
<script src="/js/xp.js" defer></script>
<script src="/js/biochem-core.js" defer></script>
<script src="/js/chem8_svg.js" defer></script>
<style>
:root{
--bg:#fffbeb; --card:#fff; --text:#1c1917; --muted:#78716c; --border:#e7e5e4;
--pri:${c.p}; --pri-d:${c.d}; --pri-l:${c.l}; --pri-soft:${c.soft};
--sh:0 4px 16px rgba(0,0,0,.06); --sh-h:0 12px 32px rgba(0,0,0,.12);
}
html.dark{ --bg:${c.bgd}; --card:${c.cardd}; --text:${c.textd}; --muted:#a8a29e; --border:#3a3026; --pri-soft:rgba(0,0,0,.2); }
*{margin:0;padding:0;box-sizing:border-box}
html,body{min-height:100vh}
body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);line-height:1.55;transition:background .25s,color .25s}
.ic{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
.hdr{position:relative;background:linear-gradient(110deg,${c.d} 0%,${c.p} 55%,${c.l} 100%);color:#fff;padding:34px 24px 30px;overflow:hidden;border-bottom:2px solid rgba(255,255,255,.18)}
.hdr::before{content:'${wmHeader}';position:absolute;right:-12px;top:50%;transform:translateY(-50%);font-family:'Unbounded',sans-serif;font-size:clamp(4rem,13vw,10rem);font-weight:900;letter-spacing:-.04em;color:transparent;-webkit-text-stroke:1.5px rgba(255,255,255,.13);line-height:1;pointer-events:none;user-select:none;z-index:0}
.hdr-inner{position:relative;z-index:1;max-width:1000px;margin:0 auto;display:flex;align-items:center;gap:16px;flex-wrap:wrap}
.hdr-back{display:inline-flex;align-items:center;gap:8px;padding:8px 14px;background:rgba(255,255,255,.16);border-radius:9px;color:#fff;text-decoration:none;font-size:.85rem;font-weight:600;transition:background .15s}
.hdr-back:hover{background:rgba(255,255,255,.26)}
.hdr-kicker{font-size:.72rem;font-weight:800;text-transform:uppercase;letter-spacing:.14em;opacity:.85}
.hdr h1{font-family:'Outfit',sans-serif;font-size:1.55rem;font-weight:900;letter-spacing:-.01em;line-height:1.25;margin-top:3px}
.hdr-side{margin-left:auto}
.hdr-btn{padding:8px 12px;background:rgba(255,255,255,.16);border:none;color:#fff;border-radius:9px;cursor:pointer;font-weight:600;font-size:.82rem;display:inline-flex;align-items:center;gap:6px;transition:background .15s;font-family:inherit}
.hdr-btn:hover{background:rgba(255,255,255,.26)}
main{max-width:1000px;margin:0 auto;padding:28px 24px 60px}
.wip{display:flex;gap:14px;align-items:flex-start;background:linear-gradient(135deg,var(--pri-soft),rgba(0,0,0,.02));border:1.5px dashed var(--pri);border-radius:16px;padding:18px 20px;margin-bottom:26px}
.wip-ic{width:42px;height:42px;border-radius:11px;background:var(--pri);color:#fff;display:flex;align-items:center;justify-content:center;flex-shrink:0}
.wip-ic svg{width:22px;height:22px;stroke:#fff;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
.wip h2{font-family:'Outfit',sans-serif;font-size:1.05rem;color:var(--pri-d);margin-bottom:4px}
html.dark .wip h2{color:var(--pri-l)}
.wip p{font-size:.9rem;color:var(--muted);line-height:1.55}
.ol-title{font-family:'Outfit',sans-serif;font-size:1.15rem;font-weight:800;margin:6px 0 14px;display:flex;align-items:center;gap:9px}
.ol-title svg{width:20px;height:20px;stroke:var(--pri);fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
.ol-list{list-style:none;background:var(--card);border:1px solid var(--border);border-radius:14px;overflow:hidden;box-shadow:var(--sh)}
.ol-para,.ol-note{display:flex;gap:12px;align-items:baseline;padding:12px 18px;border-bottom:1px solid var(--border)}
.ol-list li:last-child{border-bottom:0}
.ol-num{flex-shrink:0;min-width:46px;font-weight:800;color:var(--pri);font-size:.92rem}
.ol-name{font-size:.94rem;color:var(--text)}
.ol-note{background:var(--pri-soft);align-items:center;gap:10px}
.ol-note-ic{display:inline-flex;color:var(--pri-d)}
html.dark .ol-note-ic{color:var(--pri-l)}
.ol-note-ic svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
.ol-note span:last-child{font-size:.88rem;font-weight:600;color:var(--pri-d)}
html.dark .ol-note span:last-child{color:var(--pri-l)}
.foot{text-align:center;padding:24px 16px;color:var(--muted);font-size:.78rem;border-top:1px solid var(--border);margin-top:30px}
</style>
</head>
<body>
<header class="hdr">
<div class="hdr-inner">
<a href="/textbook/chemistry-8" class="hdr-back">
<svg class="ic" viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg>
К разделам
</a>
<div>
<div class="hdr-kicker">${esc(ch.kicker)} &middot; ${esc(ch.range)}</div>
<h1>${esc(ch.title)}</h1>
</div>
<div class="hdr-side">
<button id="theme-btn" class="hdr-btn" title="Сменить тему">
<svg class="ic" viewBox="0 0 24 24"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
<span id="theme-lab">Тёмная</span>
</button>
</div>
</div>
</header>
<main>
<section class="wip">
<div class="wip-ic">
<svg viewBox="0 0 24 24"><path d="M14.7 6.3a4 4 0 0 0-5.4 5.4l-6.3 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l6.3-6.3a4 4 0 0 0 5.4-5.4l-2.6 2.6-2-2 2.6-2.6z"/></svg>
</div>
<div>
<h2>Раздел в разработке</h2>
<p>Интерактивное наглядное наполнение этого раздела (теория, модели, симуляторы, тренажёры и боссы) добавляется поэтапно. Ниже — план параграфов раздела согласно учебнику.</p>
</div>
</section>
<div class="ol-title">
<svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h10"/></svg>
Содержание раздела
</div>
<ul class="ol-list">
${outlineHtml(ch.items)}
</ul>
</main>
<footer class="foot">
Интерактивный учебник «Химия — 8 класс» &middot; ${esc(ch.kicker)} &middot; LearnSpace
</footer>
<script>
'use strict';
const _TB_SLUG = '${ch.slug}';
(function(){
var saved = localStorage.getItem('chemistry8_theme') || localStorage.getItem('theme') || 'light';
if (saved === 'dark') document.documentElement.classList.add('dark');
var lab = document.getElementById('theme-lab');
if (lab) lab.textContent = saved === 'dark' ? 'Светлая' : 'Тёмная';
document.getElementById('theme-btn').addEventListener('click', function(){
document.documentElement.classList.toggle('dark');
var dark = document.documentElement.classList.contains('dark');
localStorage.setItem('chemistry8_theme', dark ? 'dark' : 'light');
localStorage.setItem('theme', dark ? 'dark' : 'light');
if (lab) lab.textContent = dark ? 'Светлая' : 'Тёмная';
});
})();
</script>
</body>
</html>
`;
}
// --force перезапишет уже существующие файлы; по умолчанию — пропускаем
// готовые (наполненные в фазах) страницы, чтобы не затереть контент.
const FORCE = process.argv.includes('--force');
let count = 0, skipped = 0;
for (const ch of CHAPTERS) {
const target = path.join(OUT, ch.file);
if (!FORCE && fs.existsSync(target)) {
skipped++;
console.log('skip ', ch.file, '(уже существует — наполнен в фазе)');
continue;
}
fs.writeFileSync(target, pageHtml(ch), 'utf8');
count++;
console.log('written', ch.file, '(' + ch.items.filter(i => i.t).length + ' §)');
}
console.log('done:', count, 'written,', skipped, 'skipped');
@@ -8,6 +8,9 @@ function list(req, res) {
let where = '1=1';
if (subject) { where += ' AND t.subject_slug = ?'; args.push(subject); }
if (role !== 'admin') { where += ' AND t.created_by = ?'; args.push(uid); }
// Экзаменационные варианты — это служебные строки в tests (см. import-exam9.js),
// не показываем их во вкладке «Тесты (шаблоны)» админки.
where += ' AND t.id NOT IN (SELECT test_id FROM exam9_variant_tests)';
const rows = db.prepare(`
SELECT t.id, t.title, t.subject_slug, t.description, t.created_at,
@@ -0,0 +1,56 @@
-- Chemistry 8 hub migration.
-- Creates chemistry-8 as a full hub textbook (intro + 6 chapters) in the style of physics-9:
-- chemistry-8 (hub, html_path = chemistry_8_hub.html)
-- chemistry-8-intro (Количественные понятия, §§1–9) → chemistry_8_intro.html
-- chemistry-8-ch1 (Важнейшие классы соединений, §§1023) → chemistry_8_ch1.html
-- chemistry-8-ch2 (Периодический закон и ПСХЭ, §§2428) → chemistry_8_ch2.html
-- chemistry-8-ch3 (Строение атома, §§29–35) → chemistry_8_ch3.html
-- chemistry-8-ch4 (Химическая связь, §§36–41) → chemistry_8_ch4.html
-- chemistry-8-ch5 (ОВР, §§4245) → chemistry_8_ch5.html
-- chemistry-8-ch6 (Растворы, §§46–52) → chemistry_8_ch6.html
--
-- Source: Шиманович И. Е., Красицкий В. А., Сечко О. И., Хвалюк В. Н.,
-- «Химия 8», Народная асвета, 2018. Контент авторский (наш).
-- Author left empty per project policy.
-- 1. Insert the parent chemistry-8 hub row (does not exist yet in the catalog).
INSERT INTO textbooks
(slug, subject, grade, title, author, description, html_path, para_count, color, sort_order, is_active, parent_slug)
VALUES
('chemistry-8', 'chemistry', 8, 'Химия — 8 класс',
'',
'Полный курс химии за 8 класс: количественные понятия (моль, молярная масса и объём, расчёты по уравнениям), важнейшие классы неорганических соединений, периодический закон и строение атома, химическая связь, окислительно-восстановительные реакции, растворы. 7 разделов, 52 параграфа, 4 лабораторных опыта, 4 практические работы.',
'chemistry_8_hub.html', 52, 'amber', 8, 1, NULL);
-- 2. Insert the 7 children (intro section + 6 chapters).
INSERT INTO textbooks
(slug, subject, grade, title, author, description, html_path, para_count, color, sort_order, is_active, parent_slug)
VALUES
('chemistry-8-intro', 'chemistry', 8, 'Химия 8 · Количественные понятия в химии',
'',
'§§1–9: атомы и химические элементы, простые и сложные вещества, химическое количество вещества, моль и постоянная Авогадро, молярная масса и молярный объём газов, расчёты по массе/объёму и по уравнениям реакций. Практическая работа 1.',
'chemistry_8_intro.html', 9, 'amber', 1, 1, 'chemistry-8'),
('chemistry-8-ch1', 'chemistry', 8, 'Химия 8 · Важнейшие классы неорганических соединений',
'',
'§§10–23: оксиды, кислоты, основания и соли — состав, классификация, химические свойства, получение и применение; генетическая связь между классами. 2 лабораторных опыта, 2 практические работы.',
'chemistry_8_ch1.html', 14, 'teal', 2, 1, 'chemistry-8'),
('chemistry-8-ch2', 'chemistry', 8, 'Химия 8 · Периодический закон и периодическая система',
'',
'§§24–28: систематизация элементов, амфотерность, естественные семейства элементов, периодический закон Д. И. Менделеева и строение периодической системы. Лабораторный опыт 3.',
'chemistry_8_ch2.html', 5, 'indigo', 3, 1, 'chemistry-8'),
('chemistry-8-ch3', 'chemistry', 8, 'Химия 8 · Строение атома',
'',
'§§29–35: строение атома и атомный номер, массовое число и нуклиды, изотопы и радиоактивность, электронное облако и атомная орбиталь, строение электронных оболочек, периодичность свойств, характеристика элемента по положению в ПС.',
'chemistry_8_ch3.html', 7, 'blue', 4, 1, 'chemistry-8'),
('chemistry-8-ch4', 'chemistry', 8, 'Химия 8 · Химическая связь',
'',
'§§36–41: природа химической связи, ковалентная связь (неполярная и полярная, электроотрицательность), ионная и металлическая связь, межмолекулярное взаимодействие, кристаллическое состояние вещества. Лабораторный опыт 4.',
'chemistry_8_ch4.html', 6, 'green', 5, 1, 'chemistry-8'),
('chemistry-8-ch5', 'chemistry', 8, 'Химия 8 · Окислительно-восстановительные реакции',
'',
'§§42–45: степень окисления, процессы окисления и восстановления, окислительно-восстановительные реакции и метод электронного баланса, ОВР вокруг нас.',
'chemistry_8_ch5.html', 4, 'orange', 6, 1, 'chemistry-8'),
('chemistry-8-ch6', 'chemistry', 8, 'Химия 8 · Растворы',
'',
'§§46–52: смеси веществ, растворение веществ в воде, характеристики растворимости, качественные и количественные характеристики состава растворов, массовая доля и молярная концентрация, вода и растворы в жизни человека. Практическая работа 4.',
'chemistry_8_ch6.html', 7, 'cyan', 7, 1, 'chemistry-8');
@@ -0,0 +1,65 @@
-- 042_lab_sims.sql — Контент-движок лаборатории, Фаза 4.
-- Каталог симуляций в БД: метаданные + оверрайды (вкл/выкл, порядок, теги,
-- рекомендуемые, курикулумные subject/grade). Источник истины каталога для
-- админки и (опционально) для /lab. Превью-SVG остаются в коде (frontend).
--
-- Совместимость: вкл/выкл также зеркалится в app_settings.sim_disabled_ids
-- на уровне API, поэтому существующая логика lab.html не ломается.
CREATE TABLE IF NOT EXISTS lab_sims (
id TEXT PRIMARY KEY, -- id симуляции ('pendulum', ...)
cat TEXT NOT NULL, -- math | phys | chem | bio | game
title TEXT NOT NULL,
subject TEXT, -- курикулум (Фаза 5), напр. 'physics'
grade INTEGER, -- класс (Фаза 5)
sort_order INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1, -- 0 = скрыта в каталоге
featured INTEGER NOT NULL DEFAULT 0, -- 1 = «рекомендуемая»
tags TEXT NOT NULL DEFAULT '[]', -- JSON-массив строк
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_lab_sims_sort ON lab_sims (sort_order);
-- Сид 40 симуляций в текущем порядке каталога /lab (из frontend SIMS).
INSERT OR IGNORE INTO lab_sims (id, cat, title, sort_order) VALUES
('graph', 'math', 'График функции', 1),
('graphtransform', 'math', 'Трансформации графиков', 2),
('geometry', 'math', 'Планиметрия', 3),
('triangle', 'math', 'Геометрия треугольника', 4),
('quadratic', 'math', 'Корни квадратного уравнения', 5),
('stereo', 'math', 'Стереометрия 3D', 6),
('probability', 'math', 'Теория вероятностей', 7),
('trigcircle', 'math', 'Тригонометрическая окружность', 8),
('normaldist', 'math', 'Нормальное распределение', 9),
('projectile', 'phys', 'Бросок тела', 10),
('pendulum', 'phys', 'Маятник', 11),
('collision', 'phys', 'Столкновение шаров', 12),
('emfield', 'phys', 'Электромагнитные поля', 13),
('circuit', 'phys', 'Электрические цепи', 14),
('hydrostatics', 'phys', 'Гидростатика', 15),
('dynamics', 'phys', 'Динамика', 16),
('opticsbench', 'phys', 'Оптическая скамья', 17),
('isoprocess', 'phys', 'Изопроцессы', 18),
('waves', 'phys', 'Волны и звук', 19),
('radioactive', 'phys', 'Радиоактивный распад', 20),
('race', 'phys', 'Гонка с задачами', 21),
('heatengine', 'phys', 'Тепловые двигатели', 22),
('logic', 'phys', 'Логические схемы', 23),
('molphys', 'chem', 'Молекулярная физика', 24),
('chemistry', 'chem', 'Химические реакции', 25),
('equilibrium', 'chem', 'Химическое равновесие', 26),
('electrolysis', 'chem', 'Электролиз', 27),
('bohratom', 'chem', 'Атом Бора', 28),
('orbitals', 'chem', 'Молекулярные орбитали', 29),
('titration', 'chem', 'pH и кривая титрования', 30),
('chemsandbox', 'chem', 'Химическая песочница', 31),
('stoichiometry', 'chem', 'Стехиометрия', 32),
('crystal', 'chem', 'Кристаллическая решётка', 33),
('qualanalysis', 'chem', 'Качественный анализ', 34),
('periodic', 'chem', 'Периодическая таблица', 35),
('organic', 'chem', 'Органическая химия', 36),
('solutions', 'chem', 'Растворы', 37),
('celldivision', 'bio', 'Деление клетки', 38),
('photosynthesis', 'bio', 'Фотосинтез и дыхание', 39),
('angrybirds', 'game', 'Angry Birds Physics', 40);
@@ -0,0 +1,24 @@
-- 043_lab_sim_links.sql — Контент-движок лаборатории, Фаза 5 (курикулумная привязка).
-- Связи симуляции с учебной программой: § учебника, тема, узел knowledge-map,
-- задача банка вопросов. Двусторонняя навигация (sim ↔ контент).
--
-- kind:
-- 'textbook' — ref_id = textbooks.slug
-- 'topic' — ref_id = topics.id (как текст)
-- 'kmap' — ref_id = id узла графа знаний (свободная строка)
-- 'question' — ref_id = questions.id (как текст)
-- label — необязательная человекочитаемая подпись (если не резолвится из БД).
CREATE TABLE IF NOT EXISTS lab_sim_links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sim_id TEXT NOT NULL,
kind TEXT NOT NULL, -- textbook | topic | kmap | question
ref_id TEXT NOT NULL,
label TEXT,
created_by INTEGER REFERENCES users(id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (sim_id, kind, ref_id)
);
CREATE INDEX IF NOT EXISTS idx_lab_sim_links_sim ON lab_sim_links (sim_id);
CREATE INDEX IF NOT EXISTS idx_lab_sim_links_ref ON lab_sim_links (kind, ref_id);
+299
View File
@@ -0,0 +1,299 @@
'use strict';
/* /api/lab — каталог симуляций лаборатории (контент-движок, Фазы 4-5).
*
* GET /api/lab/sims — каталог из БД (lab_sims) + legacy-флаги. auth.
* PATCH /api/lab/sims/:id — enabled/featured/tags/subject/grade. admin.
* POST /api/lab/sims/reorder — задать порядок (массив id). admin.
* GET /api/lab/sims/:id/related — связанные § / темы / kmap / задачи. auth. (Ф5)
* POST /api/lab/sims/:id/links — добавить связь. admin. (Ф5)
* DELETE /api/lab/sims/:id/links/:linkId — удалить связь. admin. (Ф5)
* GET /api/lab/links?kind=&ref_id= — обратный поиск: какие симуляции привязаны
* к данному учебнику/теме. auth. (Ф5)
*
* Совместимость: enabled зеркалится в app_settings.sim_disabled_ids, поэтому
* существующая логика lab.html (которая читает /api/settings/sims) продолжает
* корректно скрывать отключённые симуляции без правок фронта. */
const router = require('express').Router();
const db = require('../db/db');
const { authMiddleware, requireRole } = require('../middleware/auth');
const CATS = ['math', 'phys', 'chem', 'bio', 'game'];
const LINK_KINDS = ['textbook', 'topic', 'kmap', 'question'];
router.use(authMiddleware);
/* ── helpers ───────────────────────────────────────────────────────────── */
function readModuleDisabled() {
const row = db.prepare(`SELECT value FROM app_settings WHERE key = 'sim_module_disabled'`).get();
return row ? row.value === '1' : false;
}
function readLegacyDisabledIds() {
const row = db.prepare(`SELECT value FROM app_settings WHERE key = 'sim_disabled_ids'`).get();
try { return new Set(JSON.parse(row && row.value || '[]')); } catch { return new Set(); }
}
function writeLegacyDisabledIds(set) {
db.prepare(`INSERT INTO app_settings (key, value) VALUES ('sim_disabled_ids', ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`)
.run(JSON.stringify([...set]));
}
function parseTags(raw) { try { return JSON.parse(raw || '[]'); } catch { return []; } }
function rowToSim(r) {
return {
id: r.id, cat: r.cat, title: r.title,
subject: r.subject || null, grade: r.grade != null ? r.grade : null,
sort: r.sort_order, enabled: !!r.enabled, featured: !!r.featured,
tags: parseTags(r.tags),
};
}
/* ── GET /api/lab/sims ─────────────────────────────────────────────────── */
router.get('/sims', (_req, res) => {
let rows;
try {
rows = db.prepare(`SELECT * FROM lab_sims ORDER BY sort_order, id`).all();
} catch (e) {
// Деградация вместо 500: если миграция lab_sims ещё не применена на этом
// инстансе (старый процесс/другая БД) — отдаём пустой каталог, чтобы админка
// не падала. Нужно применить миграцию и перезапустить сервер.
console.warn('[lab] lab_sims недоступна (нужна миграция/перезапуск):', e.message);
return res.json({ module_disabled: readModuleDisabled(), sims: [], needs_migration: true });
}
const legacyDisabled = readLegacyDisabledIds();
const sims = rows.map(r => {
const s = rowToSim(r);
// Симуляция считается выключенной, если так сказано в lab_sims ИЛИ в legacy-списке.
s.enabled = s.enabled && !legacyDisabled.has(r.id);
return s;
});
res.json({ module_disabled: readModuleDisabled(), sims });
});
/* ── admin mutations ───────────────────────────────────────────────────────
ВАЖНО: НЕ используем blanket `router.use(requireRole('admin'))` — он применялся
бы и к ниже определённым READ-роутам Фазы 5 (/related, /links), которые должны
быть доступны любому авторизованному пользователю. Каждая мутация защищена
INLINE requireRole('admin') (так же видит route-auth линтер). */
/* PATCH /api/lab/sims/:id body: { enabled?, featured?, tags?, subject?, grade?, title?, cat? } */
router.patch('/sims/:id', requireRole('admin'), (req, res) => {
const id = String(req.params.id || '');
const row = db.prepare('SELECT * FROM lab_sims WHERE id = ?').get(id);
if (!row) return res.status(404).json({ error: 'симуляция не найдена' });
const b = req.body || {};
const sets = [];
const vals = [];
if (b.enabled !== undefined) { sets.push('enabled = ?'); vals.push(b.enabled ? 1 : 0); }
if (b.featured !== undefined) { sets.push('featured = ?'); vals.push(b.featured ? 1 : 0); }
if (b.title !== undefined) {
const t = String(b.title).trim();
if (!t) return res.status(400).json({ error: 'пустой title' });
sets.push('title = ?'); vals.push(t);
}
if (b.cat !== undefined) {
if (!CATS.includes(b.cat)) return res.status(400).json({ error: 'неверная категория' });
sets.push('cat = ?'); vals.push(b.cat);
}
if (b.subject !== undefined) { sets.push('subject = ?'); vals.push(b.subject ? String(b.subject) : null); }
if (b.grade !== undefined) {
const g = b.grade === null || b.grade === '' ? null : Number(b.grade);
if (g !== null && (!Number.isInteger(g) || g < 1 || g > 11)) {
return res.status(400).json({ error: 'grade должен быть 1..11 или null' });
}
sets.push('grade = ?'); vals.push(g);
}
if (b.tags !== undefined) {
if (!Array.isArray(b.tags)) return res.status(400).json({ error: 'tags должен быть массивом' });
const clean = b.tags.map(t => String(t).trim()).filter(Boolean).slice(0, 20);
sets.push('tags = ?'); vals.push(JSON.stringify(clean));
}
if (!sets.length) return res.status(400).json({ error: 'нет полей для обновления' });
sets.push("updated_at = datetime('now')");
vals.push(id);
db.prepare(`UPDATE lab_sims SET ${sets.join(', ')} WHERE id = ?`).run(...vals);
// Зеркалим enabled в legacy sim_disabled_ids для совместимости с lab.html.
if (b.enabled !== undefined) {
const set = readLegacyDisabledIds();
if (b.enabled) set.delete(id); else set.add(id);
writeLegacyDisabledIds(set);
}
const updated = db.prepare('SELECT * FROM lab_sims WHERE id = ?').get(id);
res.json({ ok: true, sim: rowToSim(updated) });
});
/* POST /api/lab/sims/reorder body: { order: [id, id, ...] } */
router.post('/sims/reorder', requireRole('admin'), (req, res) => {
const order = (req.body && req.body.order) || [];
if (!Array.isArray(order) || !order.length) {
return res.status(400).json({ error: 'order должен быть непустым массивом id' });
}
const exists = new Set(db.prepare('SELECT id FROM lab_sims').all().map(r => r.id));
for (const id of order) {
if (!exists.has(id)) return res.status(400).json({ error: 'неизвестный id: ' + id });
}
const upd = db.prepare("UPDATE lab_sims SET sort_order = ?, updated_at = datetime('now') WHERE id = ?");
db.transaction(() => {
order.forEach((id, i) => upd.run(i + 1, id));
})();
res.json({ ok: true, count: order.length });
});
/* ════════════════════════════════════════════════════════════════════════
Курикулумная привязка (Фаза 5) — связи симуляции ↔ контент.
════════════════════════════════════════════════════════════════════════ */
// Безопасно прочитать связи симуляции (если таблицы ещё нет — пустой массив).
function readLinks(simId) {
try {
return db.prepare(
'SELECT id, sim_id, kind, ref_id, label FROM lab_sim_links WHERE sim_id = ? ORDER BY kind, id'
).all(simId);
} catch (e) {
return null; // null => таблица недоступна (нужна миграция)
}
}
// Обогатить связь человекочитаемой меткой и навигационным href.
function decorateLink(l) {
const out = { id: l.id, kind: l.kind, ref_id: l.ref_id, label: l.label || null };
if (l.kind === 'textbook') {
const tb = db.prepare('SELECT title, subject, grade FROM textbooks WHERE slug = ?').get(l.ref_id);
if (tb) { out.label = out.label || tb.title; out.subject = tb.subject; out.grade = tb.grade; }
out.href = '/textbooks?book=' + encodeURIComponent(l.ref_id);
} else if (l.kind === 'topic') {
const tp = db.prepare('SELECT name FROM topics WHERE id = ?').get(Number(l.ref_id));
if (tp) out.label = out.label || tp.name;
} else if (l.kind === 'question') {
out.href = null; // задачи открываются в банке вопросов отдельным контекстом
}
if (!out.label) out.label = l.kind + ':' + l.ref_id;
return out;
}
/* GET /api/lab/sims/:id/related → { sim, links:{ textbook:[], topic:[], kmap:[], question:[] } } */
router.get('/sims/:id/related', authMiddleware, (req, res) => {
const id = String(req.params.id || '');
const sim = db.prepare('SELECT id, title FROM lab_sims WHERE id = ?').get(id);
// sim может отсутствовать в lab_sims (если миграция 042 не применена) — не 404,
// т.к. связи всё равно могут существовать; вернём то, что есть.
const rows = readLinks(id);
if (rows === null) return res.json({ sim: sim || { id }, links: {}, needs_migration: true });
const links = { textbook: [], topic: [], kmap: [], question: [] };
for (const l of rows) {
const d = decorateLink(l);
(links[l.kind] || (links[l.kind] = [])).push(d);
}
res.json({ sim: sim || { id }, links });
});
/* GET /api/lab/links?kind=textbook&ref_id=algebra-8
→ { sims:[{id,title,cat,enabled}] } — какие (включённые) симуляции привязаны. */
router.get('/links', (req, res) => {
const kind = String(req.query.kind || '');
const refId = String(req.query.ref_id || '');
if (!LINK_KINDS.includes(kind) || !refId) {
return res.status(400).json({ error: 'kind и ref_id обязательны' });
}
let rows;
try {
rows = db.prepare(`
SELECT s.id, s.title, s.cat, s.enabled
FROM lab_sim_links l JOIN lab_sims s ON s.id = l.sim_id
WHERE l.kind = ? AND l.ref_id = ?
ORDER BY s.sort_order, s.id
`).all(kind, refId);
} catch (e) {
return res.json({ sims: [], needs_migration: true });
}
const legacyDisabled = readLegacyDisabledIds();
const sims = rows
.map(r => ({ id: r.id, title: r.title, cat: r.cat, enabled: !!r.enabled && !legacyDisabled.has(r.id) }))
.filter(s => s.enabled); // наружу отдаём только доступные
res.json({ sims });
});
/* GET /api/lab/links/all?kind=textbook
→ { byRef: { <ref_id>: [{id,title,cat}] } } — пакетный обратный поиск для всех
ref_id данного типа за один запрос (избегаем N+1 на странице каталога учебников).
Отдаёт только включённые симуляции. */
router.get('/links/all', (req, res) => {
const kind = String(req.query.kind || '');
if (!LINK_KINDS.includes(kind)) {
return res.status(400).json({ error: 'неверный kind' });
}
let rows;
try {
rows = db.prepare(`
SELECT l.ref_id, s.id, s.title, s.cat, s.enabled, s.sort_order
FROM lab_sim_links l JOIN lab_sims s ON s.id = l.sim_id
WHERE l.kind = ?
ORDER BY s.sort_order, s.id
`).all(kind);
} catch (e) {
return res.json({ byRef: {}, needs_migration: true });
}
const legacyDisabled = readLegacyDisabledIds();
const byRef = {};
for (const r of rows) {
if (!r.enabled || legacyDisabled.has(r.id)) continue;
(byRef[r.ref_id] || (byRef[r.ref_id] = [])).push({ id: r.id, title: r.title, cat: r.cat });
}
res.json({ byRef });
});
/* ── admin: управление связями ─────────────────────────────────────────── */
/* POST /api/lab/sims/:id/links body: { kind, ref_id, label? } */
router.post('/sims/:id/links', requireRole('admin'), (req, res) => {
const simId = String(req.params.id || '');
if (!db.prepare('SELECT 1 FROM lab_sims WHERE id = ?').get(simId)) {
return res.status(404).json({ error: 'симуляция не найдена' });
}
const b = req.body || {};
const kind = String(b.kind || '');
const refId = String(b.ref_id || '').trim();
if (!LINK_KINDS.includes(kind)) return res.status(400).json({ error: 'неверный kind' });
if (!refId) return res.status(400).json({ error: 'ref_id обязателен' });
// Валидация существования цели (мягкая — kmap/question произвольны).
if (kind === 'textbook' && !db.prepare('SELECT 1 FROM textbooks WHERE slug = ?').get(refId)) {
return res.status(404).json({ error: 'учебник не найден: ' + refId });
}
if (kind === 'topic') {
const tid = Number(refId);
if (!Number.isInteger(tid) || !db.prepare('SELECT 1 FROM topics WHERE id = ?').get(tid)) {
return res.status(404).json({ error: 'тема не найдена: ' + refId });
}
}
const label = b.label != null ? String(b.label).trim().slice(0, 200) || null : null;
try {
const info = db.prepare(
'INSERT INTO lab_sim_links (sim_id, kind, ref_id, label, created_by) VALUES (?, ?, ?, ?, ?)'
).run(simId, kind, refId, label, req.user.id);
const created = db.prepare('SELECT id, sim_id, kind, ref_id, label FROM lab_sim_links WHERE id = ?')
.get(info.lastInsertRowid);
res.json({ ok: true, link: decorateLink(created) });
} catch (e) {
if (/UNIQUE/i.test(e.message)) return res.status(409).json({ error: 'такая связь уже есть' });
throw e;
}
});
/* DELETE /api/lab/sims/:id/links/:linkId */
router.delete('/sims/:id/links/:linkId', requireRole('admin'), (req, res) => {
const simId = String(req.params.id || '');
const linkId = Number(req.params.linkId);
if (!Number.isInteger(linkId)) return res.status(400).json({ error: 'неверный linkId' });
const info = db.prepare('DELETE FROM lab_sim_links WHERE id = ? AND sim_id = ?').run(linkId, simId);
if (!info.changes) return res.status(404).json({ error: 'связь не найдена' });
res.json({ ok: true });
});
module.exports = router;
+2
View File
@@ -55,6 +55,7 @@ const examPrepRoutes = require('./routes/exam-prep');
const textbookRoutes = require('./routes/textbooks');
const accessRoutes = require('./routes/access');
const teacherStudentsRoutes = require('./routes/teacherStudents');
const labRoutes = require('./routes/lab');
const { requestId, errorHandler } = require('./middleware/errorHandler');
@@ -177,6 +178,7 @@ app.use('/api/exam-prep', examPrepRoutes);
app.use('/api/textbooks', textbookRoutes);
app.use('/api/access', accessRoutes);
app.use('/api/teacher-students', teacherStudentsRoutes);
app.use('/api/lab', labRoutes);
/* ── Public features endpoint (merges global + per-class for authenticated students) ── */
const _featDb = require('./db/db');
+63
View File
@@ -0,0 +1,63 @@
'use strict';
/*
* jsdom-смоук виджетов chem8_svg.js: реальная отрисовка в DOM, ввод, проверка.
* Ловит рантайм-ошибки DOM-манипуляций, которые не видны в чистых юнит-тестах.
*/
const test = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const { JSDOM } = require('jsdom');
const SRC = fs.readFileSync(
path.join(__dirname, '..', '..', 'frontend', 'js', 'chem8_svg.js'), 'utf8');
function mkDom() {
const dom = new JSDOM('<!DOCTYPE html><body><div id="m"></div><div id="b"></div></body>');
// выполняем модуль так, что его `window` === jsdom-окно
new Function('window', SRC)(dom.window);
return { dom, C: dom.window.Chem8, doc: dom.window.document };
}
function fire(el, type) {
el.dispatchEvent(new el.ownerDocument.defaultView.Event(type, { bubbles: true }));
}
test('moleTriangle монтируется и считает m = n·M', () => {
const { C, doc } = mkDom();
const api = C.moleTriangle(doc.getElementById('m'), {});
assert.ok(api && api.el, 'виджет смонтирован');
const inputs = doc.querySelectorAll('#m input[data-k]');
assert.equal(inputs.length, 3, '3 поля');
const byKey = {};
inputs.forEach(i => { byKey[i.getAttribute('data-k')] = i; });
// вводим n=2, затем M=18 → ожидаем m=36
byKey.n.value = '2'; fire(byKey.n, 'input');
byKey.M.value = '18'; fire(byKey.M, 'input');
const out = doc.querySelector('#m [data-out]');
assert.ok(/36/.test(out.textContent), 'm = 36 вычислено: ' + out.textContent);
});
test('equationBalancer: неверные коэффициенты → дисбаланс, верные → баланс', () => {
const { C, doc } = mkDom();
const api = C.equationBalancer(doc.getElementById('b'),
{ skeleton: 'H2 + O2 -> H2O', solution: [2, 1, 2] });
assert.ok(api && api.check, 'виджет смонтирован');
// по умолчанию все коэффициенты = 1 → не сбалансировано
assert.equal(api.check(), false, '1·H2 + 1·O2 -> 1·H2O не сбалансировано');
const out = doc.querySelector('#b [data-out]');
assert.ok(out.className.includes('bad'), 'подсветка дисбаланса');
// применяем решение через кнопку
doc.querySelector('#b [data-solve]').dispatchEvent(
new doc.defaultView.Event('click', { bubbles: true }));
assert.ok(out.className.includes('ok'), 'после решения — сбалансировано: ' + out.className);
});
test('equationBalancer считает атомы для сложной реакции', () => {
const { C, doc } = mkDom();
const api = C.equationBalancer(doc.getElementById('b'),
{ skeleton: 'Al + HCl -> AlCl3 + H2', solution: [2, 6, 2, 3] });
const coefs = doc.querySelectorAll('#b .ceqb-coef');
[2, 6, 2, 3].forEach((v, i) => { coefs[i].value = String(v); });
assert.equal(api.check(), true, '2Al + 6HCl -> 2AlCl3 + 3H2 сбалансировано');
});
+236
View File
@@ -0,0 +1,236 @@
'use strict';
/*
* Полностраничная jsdom-проверка глав «Химия 8» (SPA на chem8_engine.js):
* выполняем реальный HTML + движок + виджеты, даём таймерам отработать, проверяем
* para-selector, активный §, монтаж виджетов — без ошибок скриптов.
*/
const test = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const { JSDOM, VirtualConsole } = require('jsdom');
const ROOT = path.join(__dirname, '..', '..');
const readF = p => fs.readFileSync(path.join(ROOT, p), 'utf8');
const wait = ms => new Promise(r => setTimeout(r, ms));
function buildPage(file, widgetsSrc) {
let html = readF('frontend/textbooks/' + file);
const inl = {
'/js/biochem-core.js': readF('frontend/js/biochem-core.js'),
'/js/chem8_svg.js': readF('frontend/js/chem8_svg.js'),
'/js/chem8_mol.js': readF('frontend/js/chem8_mol.js'),
[widgetsSrc]: readF('frontend/js' + widgetsSrc.replace('/js', '')),
'/js/chem8_engine.js': readF('frontend/js/chem8_engine.js')
};
html = html
.replace(/<script defer src="https:\/\/cdn[^"]*"[^>]*><\/script>/g, '')
.replace(/<script src="\/js\/api\.js" defer><\/script>/, '<script>window.renderMathInElement=function(){};</script>')
.replace(/<script src="\/js\/xp\.js" defer><\/script>/, '');
Object.keys(inl).forEach(src => {
html = html.replace(new RegExp('<script src="' + src + '" defer><\\/script>'), () => '<script>\n' + inl[src] + '\n</script>');
});
return html;
}
async function loadDom(file, widgetsSrc) {
const errors = [];
const vc = new VirtualConsole();
vc.on('jsdomError', e => errors.push(e.message));
const dom = new JSDOM(buildPage(file, widgetsSrc), {
runScripts: 'dangerously', pretendToBeVisual: true, virtualConsole: vc, url: 'http://localhost/',
beforeParse(w) { w.scrollTo = function () {}; }
});
await wait(180);
return { dom, errors, doc: dom.window.document };
}
/* ── Вводный раздел ── */
test('intro: SPA без ошибок, 11 карточек, §1 активен, виджеты', async () => {
const { doc, errors } = await loadDom('chemistry_8_intro.html', '/js/chem8_intro_widgets.js');
assert.deepEqual(errors, [], 'нет ошибок: ' + errors.join(' | '));
assert.equal(doc.querySelectorAll('#psel-grid .psel-card').length, 11, '11 карточек');
assert.ok(doc.querySelector('.sec.active') && doc.querySelector('.sec.active').id === 'sec-p1', '§1 активен');
assert.ok(doc.querySelectorAll('#p1-el .el-cell').length > 10, 'карта элементов');
doc.defaultView.goTo('p6'); await wait(120);
assert.ok(doc.querySelector('#p6-mount .mtri'), 'треугольник §6');
});
/* ── Глава 1 ── */
test('ch1: SPA без ошибок, 15 карточек, §10 активен', async () => {
const { doc, errors } = await loadDom('chemistry_8_ch1.html', '/js/chem8_ch1_widgets.js');
assert.deepEqual(errors, [], 'нет ошибок: ' + errors.join(' | '));
assert.equal(doc.querySelectorAll('#psel-grid .psel-card').length, 15, '14 § + финал');
assert.ok(doc.querySelector('.sec.active') && doc.querySelector('.sec.active').id === 'sec-p10', '§10 активен');
assert.ok(doc.querySelector('#p10-body .para-hero'), 'para-hero §10');
});
test('ch1: флагман-виджеты монтируются (классификатор, растворимость, ряд активности)', async () => {
const { doc } = await loadDom('chemistry_8_ch1.html', '/js/chem8_ch1_widgets.js');
doc.defaultView.goTo('p10'); await wait(120);
assert.ok(doc.querySelector('#c-ox-cls .cls-chip'), 'классификатор оксидов §10');
doc.defaultView.goTo('p13'); await wait(120);
assert.ok(doc.querySelector('#c-acid-ind .ind-strip'), 'индикатор §13');
doc.defaultView.goTo('p19'); await wait(120);
assert.ok(doc.querySelector('#c-salt-sol .sol-tab'), 'таблица растворимости §19');
doc.defaultView.goTo('p14'); await wait(120);
assert.ok(doc.querySelector('#c-acid-act .act-cell'), 'ряд активности §14');
});
test('ch1: тренажёр задач отрисован для §10', async () => {
const { doc } = await loadDom('chemistry_8_ch1.html', '/js/chem8_ch1_widgets.js');
await wait(150);
assert.ok(doc.querySelectorAll('#navDotsp10 .nav-dot').length >= 4, 'навигация по задачам §10');
});
test('ch1: генетическая карта §22 монтируется (U3)', async () => {
const { doc } = await loadDom('chemistry_8_ch1.html', '/js/chem8_ch1_widgets.js');
doc.defaultView.goTo('p22'); await wait(120);
assert.ok(doc.querySelectorAll('#c-genetic .gm-edge').length >= 6, 'граф классов §22');
});
test('ch1: карта связей в финале главы монтируется (U6)', async () => {
const { doc } = await loadDom('chemistry_8_ch1.html', '/js/chem8_ch1_widgets.js');
doc.defaultView.goTo('final1'); await wait(120);
assert.ok(doc.querySelectorAll('#c-concept .gm-edge').length >= 3, 'карта связей понятий финала');
});
/* ── Глава 2 ── */
test('ch2: SPA без ошибок, 6 карточек, §24 активен, ПСХЭ', async () => {
const { doc, errors } = await loadDom('chemistry_8_ch2.html', '/js/chem8_ch2_widgets.js');
assert.deepEqual(errors, [], 'нет ошибок: ' + errors.join(' | '));
assert.equal(doc.querySelectorAll('#psel-grid .psel-card').length, 6, '5 § + финал');
assert.ok(doc.querySelector('.sec.active') && doc.querySelector('.sec.active').id === 'sec-p24', '§24 активен');
await wait(120);
assert.ok(doc.querySelectorAll('#c-pt-metals .pt-cell').length > 80, 'ПСХЭ §24 (90 элементов)');
});
test('ch2: амфотерность §25 и семейства §26 монтируются', async () => {
const { doc } = await loadDom('chemistry_8_ch2.html', '/js/chem8_ch2_widgets.js');
doc.defaultView.goTo('p25'); await wait(120);
assert.ok(doc.querySelector('#c-amph .amph-btn'), 'амфотерность §25');
doc.defaultView.goTo('p26'); await wait(120);
assert.ok(doc.querySelectorAll('#c-pt-fam .pt-cell').length > 80, 'ПСХЭ семейства §26');
});
/* ── Глава 3 ── */
test('ch3: SPA без ошибок, 8 карточек, §29 активен, модель атома', async () => {
const { doc, errors } = await loadDom('chemistry_8_ch3.html', '/js/chem8_ch3_widgets.js');
assert.deepEqual(errors, [], 'нет ошибок: ' + errors.join(' | '));
assert.equal(doc.querySelectorAll('#psel-grid .psel-card').length, 8, '7 § + финал');
assert.ok(doc.querySelector('.sec.active') && doc.querySelector('.sec.active').id === 'sec-p29', '§29 активен');
await wait(120);
assert.ok(doc.querySelector('#c-atom .as-svg'), 'модель атома §29');
});
test('ch3: нуклид §30 и паспорт §35 монтируются', async () => {
const { doc } = await loadDom('chemistry_8_ch3.html', '/js/chem8_ch3_widgets.js');
doc.defaultView.goTo('p30'); await wait(120);
assert.ok(doc.querySelector('#c-nuclide #nz'), 'калькулятор нуклида §30');
doc.defaultView.goTo('p35'); await wait(120);
assert.ok(doc.querySelectorAll('#c-passport .pt-cell').length > 80, 'ПСХЭ паспорта §35');
});
/* ── Глава 4 ── */
test('ch4: SPA без ошибок, 7 карточек, §36 активен, тип связи', async () => {
const { doc, errors } = await loadDom('chemistry_8_ch4.html', '/js/chem8_ch4_widgets.js');
assert.deepEqual(errors, [], 'нет ошибок: ' + errors.join(' | '));
assert.equal(doc.querySelectorAll('#psel-grid .psel-card').length, 7, '6 § + финал');
assert.ok(doc.querySelector('.sec.active') && doc.querySelector('.sec.active').id === 'sec-p36', '§36 активен');
doc.defaultView.goTo('p37'); await wait(120);
assert.ok(doc.querySelector('#c-bond1 .bt-svg'), 'виджет типа связи §37');
doc.defaultView.goTo('p38'); await wait(120);
assert.ok(doc.querySelector('#c-bond2 .bt-out'), 'виджет полярности §38');
});
test('ch4: 3D-модели молекул §38 и решётки §41 монтируются (U4)', async () => {
const { doc, errors } = await loadDom('chemistry_8_ch4.html', '/js/chem8_ch4_widgets.js');
assert.deepEqual(errors, [], 'нет ошибок: ' + errors.join(' | '));
doc.defaultView.goTo('p38'); await wait(140);
assert.ok(doc.querySelector('#c-mol .mol-sel'), 'выбор молекулы §38');
assert.ok(doc.querySelector('#c-mol canvas'), 'canvas 3D-модели §38');
assert.ok(doc.querySelector('#c-mol .mol-info'), 'инфо о молекуле §38');
doc.defaultView.goTo('p41'); await wait(140);
assert.ok(doc.querySelector('#c-lattice .lat-sel'), 'выбор решётки §41');
assert.ok(doc.querySelector('#c-lattice canvas'), 'canvas решётки §41');
});
/* ── Глава 5 ── */
test('ch5: SPA без ошибок, 5 карточек, §42 активен, с.о. и баланс', async () => {
const { doc, errors } = await loadDom('chemistry_8_ch5.html', '/js/chem8_ch5_widgets.js');
assert.deepEqual(errors, [], 'нет ошибок: ' + errors.join(' | '));
assert.equal(doc.querySelectorAll('#psel-grid .psel-card').length, 5, '4 § + финал');
assert.ok(doc.querySelector('.sec.active') && doc.querySelector('.sec.active').id === 'sec-p42', '§42 активен');
await wait(120);
assert.ok(doc.querySelector('#c-ox .ox-out'), 'калькулятор с.о. §42');
doc.defaultView.goTo('p44'); await wait(120);
assert.ok(doc.querySelector('#c-redox-pick option'), 'электронный баланс §44');
});
/* ── Глоссарий (U2/Phase 8) ── */
test('glossary: кнопка, модалка, авто-подсветка терминов', async () => {
const src = readF('frontend/js/chem8_glossary.js');
const dom = new JSDOM('<!DOCTYPE html><body><div class="card-body"><p>Оксид — это сложное вещество. Кислота реагирует с основанием в реакции нейтрализации.</p></div></body>',
{ runScripts: 'outside-only', pretendToBeVisual: true, url: 'http://localhost/' });
new Function('window', src)(dom.window);
await wait(20);
const doc = dom.window.document;
assert.ok(dom.window.Chem8Glossary, 'window.Chem8Glossary определён');
assert.ok(Object.keys(dom.window.Chem8Glossary.terms).length > 40, '>40 терминов');
assert.ok(doc.querySelector('.gl-fab'), 'плавающая кнопка глоссария');
// авто-подсветка терминов в .card-body
assert.ok(doc.querySelectorAll('.card-body .gloss').length >= 2, 'термины подсвечены: ' + doc.querySelectorAll('.gloss').length);
// открытие модалки
dom.window.Chem8Glossary.open();
assert.ok(doc.querySelector('.gl-modal.show'), 'модалка открыта');
assert.ok(doc.querySelectorAll('.gl-modal .gl-item').length > 40, 'список терминов в модалке');
});
/* ── Хаб: финал курса (Phase 7) ── */
function buildHub() {
let html = readF('frontend/textbooks/chemistry_8_hub.html');
return html
.replace(/<script defer src="https:\/\/cdn[^"]*"[^>]*><\/script>/g, '')
.replace(/<script src="\/js\/api\.js" defer><\/script>/, '<script>window.renderMathInElement=function(){};</script>')
.replace(/<script src="\/js\/xp\.js" defer><\/script>/, '');
}
async function loadHub() {
const errors = []; const vc = new VirtualConsole(); vc.on('jsdomError', e => errors.push(e.message));
const dom = new JSDOM(buildHub(), { runScripts: 'dangerously', pretendToBeVisual: true, virtualConsole: vc, url: 'http://localhost/', beforeParse(w){ w.scrollTo=function(){}; } });
await wait(60);
return { dom, errors, doc: dom.window.document };
}
test('hub: финал курса — 10 боссов рендерятся при раскрытии, босс решается', async () => {
const { doc, errors } = await loadHub();
assert.deepEqual(errors, [], 'нет ошибок: ' + errors.join(' | '));
assert.equal(doc.querySelectorAll('.ch-grid .ch-card').length, 7, '7 карточек глав');
// раскрыть финал
doc.getElementById('final-head').dispatchEvent(new doc.defaultView.Event('click', { bubbles: true }));
await wait(40);
assert.equal(doc.querySelectorAll('#fin-bosses-container .boss-card').length, 10, '10 боссов');
// решить босс 1 (Mr Ca(OH)2 = 74)
const inp = doc.getElementById('fb-1-inp'), go = doc.getElementById('fb-1-go');
inp.value = '74'; go.dispatchEvent(new doc.defaultView.Event('click', { bubbles: true }));
assert.ok(doc.getElementById('fb-1-card').classList.contains('solved'), 'босс 1 повержен');
});
/* ── Глава 6 ── */
test('ch6: SPA без ошибок, 8 карточек, §46 активен, w/c калькуляторы', async () => {
const { doc, errors } = await loadDom('chemistry_8_ch6.html', '/js/chem8_ch6_widgets.js');
assert.deepEqual(errors, [], 'нет ошибок: ' + errors.join(' | '));
assert.equal(doc.querySelectorAll('#psel-grid .psel-card').length, 8, '7 § + финал');
assert.ok(doc.querySelector('.sec.active') && doc.querySelector('.sec.active').id === 'sec-p46', '§46 активен');
await wait(120);
assert.ok(doc.querySelector('#c-mix .cls-chip'), 'классификатор смесей §46');
doc.defaultView.goTo('p50'); await wait(120);
assert.ok(doc.querySelector('#c-wcalc #w-go'), 'калькулятор w §50');
doc.defaultView.goTo('p51'); await wait(120);
assert.ok(doc.querySelector('#c-ccalc #c-go'), 'калькулятор c §51');
});
test('ch6: анимация растворения §47 монтируется (U3)', async () => {
const { doc } = await loadDom('chemistry_8_ch6.html', '/js/chem8_ch6_widgets.js');
doc.defaultView.goTo('p47'); await wait(120);
assert.ok(doc.querySelector('#c-dissoc .ds-svg'), 'анимация диссоциации §47');
});
+230
View File
@@ -0,0 +1,230 @@
'use strict';
/*
* Phase 0 тесты учебника «Химия 8» (hub + 7 глав).
* 1. Чистые примитивы frontend/js/chem8_svg.js (window.Chem8): formula/ionLabel/chemEq.
* 2. Целостность каркаса: хаб + 7 файлов глав существуют, slug'и согласованы,
* сумма параграфов = 52, миграция 041 содержит родителя + 7 детей.
*/
const test = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.join(__dirname, '..', '..');
const TB = path.join(ROOT, 'frontend', 'textbooks');
// --- shim browser global, load the frontend module ---
global.window = global;
require(path.join(ROOT, 'frontend', 'js', 'chem8_svg.js'));
const C = global.Chem8;
test('Chem8.formula — числовые индексы в подстрочные', () => {
assert.equal(C.formula('CaCO3'), 'CaCO₃');
assert.equal(C.formula('H2O'), 'H₂O');
assert.equal(C.formula('Al2(SO4)3'), 'Al₂(SO₄)₃');
assert.equal(C.formula('NaCl'), 'NaCl');
});
test('Chem8.ionLabel — заряд ионов надстрочным', () => {
assert.equal(C.ionLabel('Na', 1), 'Na⁺');
assert.equal(C.ionLabel('Ca', 2), 'Ca²⁺');
assert.equal(C.ionLabel('Cl', -1), 'Cl⁻');
assert.equal(C.ionLabel('SO4', -2), 'SO₄²⁻');
assert.equal(C.ionLabel('Fe', 3), 'Fe³⁺');
assert.equal(C.ionLabel('Na', 0), 'Na');
});
test('Chem8.chemEq — стрелка, индексы, газ', () => {
const html = C.chemEq('2Na + 2H2O -> 2NaOH + H2^');
assert.ok(html.includes('2H₂O'), 'индексы воды');
assert.ok(html.includes('→'), 'стрелка реакции');
assert.ok(html.includes('H₂↑'), 'значок газа');
assert.ok(html.includes('class="ceq"'), 'обёртка');
});
test('Chem8.chemEq — обратимая реакция и осадок', () => {
const rev = C.chemEq('N2 + 3H2 <-> 2NH3');
assert.ok(rev.includes('⇌'), 'обратимая стрелка');
const prec = C.chemEq('AgNO3 + NaCl -> AgClv + NaNO3');
assert.ok(prec.includes('AgCl↓'), 'значок осадка');
});
test('Chem8.molarMass — школьные Ar (Mr из учебника)', () => {
assert.equal(C.molarMass('H2O'), 18);
assert.equal(C.molarMass('CaCO3'), 100);
assert.equal(C.molarMass('H2SO4'), 98);
assert.equal(C.molarMass('Al2(SO4)3'), 342);
assert.equal(C.molarMass('NaOH'), 40);
assert.ok(Number.isNaN(C.molarMass('Xx9')), 'неизвестный элемент → NaN');
});
test('Chem8.elementCounts — скобки и индексы', () => {
assert.deepEqual(C.elementCounts('Ca(OH)2'), { Ca: 1, O: 2, H: 2 });
assert.deepEqual(C.elementCounts('Al2(SO4)3'), { Al: 2, S: 3, O: 12 });
assert.deepEqual(C.elementCounts('CO2'), { C: 1, O: 2 });
});
test('Chem8 — оставшиеся заглушки возвращают null и не падают', () => {
for (const fn of ['redoxBalancer', 'orbitalDiagram']) {
assert.equal(typeof C[fn], 'function', fn + ' определён');
assert.equal(C[fn]({}), null, fn + ' заглушка возвращает null');
}
});
test('Chem8 — Phase 2 виджеты экспортированы как функции', () => {
for (const fn of ['testTube', 'indicatorScale', 'classifier', 'solubilityTable', 'activitySeries']) {
assert.equal(typeof C[fn], 'function', fn + ' реализован');
}
assert.ok(C.testTube({ precipitate: '#88c' }).includes('<svg'), 'testTube → SVG');
});
test('Chem8 — движки расчётов экспортированы как функции', () => {
for (const fn of ['moleTriangle', 'equationBalancer']) {
assert.equal(typeof C[fn], 'function', fn + ' определён');
}
});
// --- каркас страниц ---
const CHILDREN = [
{ slug: 'chemistry-8-intro', file: 'chemistry_8_intro.html', paras: 9 },
{ slug: 'chemistry-8-ch1', file: 'chemistry_8_ch1.html', paras: 14 },
{ slug: 'chemistry-8-ch2', file: 'chemistry_8_ch2.html', paras: 5 },
{ slug: 'chemistry-8-ch3', file: 'chemistry_8_ch3.html', paras: 7 },
{ slug: 'chemistry-8-ch4', file: 'chemistry_8_ch4.html', paras: 6 },
{ slug: 'chemistry-8-ch5', file: 'chemistry_8_ch5.html', paras: 4 },
{ slug: 'chemistry-8-ch6', file: 'chemistry_8_ch6.html', paras: 7 }
];
test('сумма параграфов глав = 52', () => {
assert.equal(CHILDREN.reduce((a, c) => a + c.paras, 0), 52);
});
test('хаб chemistry_8_hub.html существует и ссылается на все 7 глав', () => {
const hub = fs.readFileSync(path.join(TB, 'chemistry_8_hub.html'), 'utf8');
assert.ok(hub.includes('var TOTAL = 52'), 'TOTAL=52');
for (const ch of CHILDREN) {
assert.ok(hub.includes('/textbook/' + ch.slug), 'ссылка на ' + ch.slug);
}
assert.ok(hub.includes('/api/textbooks/chemistry-8/children'), 'грузит детей');
});
test('каждая глава существует, ссылается на хаб и подключает chem8', () => {
for (const ch of CHILDREN) {
const html = fs.readFileSync(path.join(TB, ch.file), 'utf8');
assert.ok(html.includes('/textbook/chemistry-8"'), ch.file + ' ссылка назад в хаб');
assert.ok(html.includes('/js/chem8_svg.js'), ch.file + ' подключает chem8_svg');
// все 8 страниц (intro + 6 глав) перестроены на движок chem8_engine.js (SPA)
assert.ok(html.includes("slug:'" + ch.slug + "'"), ch.file + ' slug в CHEM8_CFG');
assert.ok(html.includes('/js/chem8_engine.js'), ch.file + ' подключает движок');
}
});
test('Phase 1 — раздел intro перестроен на движок (SPA, эталон)', () => {
const html = fs.readFileSync(path.join(TB, 'chemistry_8_intro.html'), 'utf8');
assert.ok(html.includes('id="psel-grid"'), 'para-selector');
for (let i = 1; i <= 9; i++) assert.ok(html.includes('id="sec-p' + i + '"'), '§' + i + ' секция');
assert.ok(html.includes('id="sec-pr1"'), 'ПР1 секция');
assert.ok(html.includes('id="sec-final1"'), 'финал-секция');
assert.ok(html.includes('window.POOLS'), 'тренажёр задач (POOLS)');
assert.ok(html.includes('window.BUILDERS'), 'builders §');
assert.ok(html.includes('function build_p6'), 'build_p6 (треугольник)');
assert.ok(html.includes('/css/chem8-textbook.css'), 'фреймворк-CSS');
assert.ok(html.includes('/js/chem8_intro_widgets.js'), 'виджеты раздела');
assert.ok(!html.includes('Раздел в разработке'), 'баннер-заглушка убран');
});
test('Phase 2 — Глава 1 построена на движке (§10–23 + лаб/ПР + финал)', () => {
const html = fs.readFileSync(path.join(TB, 'chemistry_8_ch1.html'), 'utf8');
assert.ok(html.includes('id="psel-grid"'), 'para-selector');
for (let i = 10; i <= 23; i++) assert.ok(html.includes('id="sec-p' + i + '"'), '§' + i + ' секция');
assert.ok(html.includes('id="sec-final1"'), 'финал');
assert.ok(html.includes('id="c-ox-cls"'), 'классификатор оксидов');
assert.ok(html.includes('id="c-salt-sol"'), 'таблица растворимости');
assert.ok(html.includes('Лабораторный опыт 1'), 'Лаб.1');
assert.ok(html.includes('Практическая работа 2'), 'ПР2');
assert.ok(html.includes('/js/chem8_ch1_widgets.js'), 'виджеты главы');
assert.ok(!html.includes('Раздел в разработке'), 'заглушка убрана');
});
test('Phase 3 — Глава 2 построена на движке (§24–28 + Лаб.3 + финал)', () => {
const html = fs.readFileSync(path.join(TB, 'chemistry_8_ch2.html'), 'utf8');
for (let i = 24; i <= 28; i++) assert.ok(html.includes('id="sec-p' + i + '"'), '§' + i + ' секция');
assert.ok(html.includes('id="c-pt-metals"'), 'ПСХЭ §24');
assert.ok(html.includes('id="c-amph"'), 'амфотерность §25');
assert.ok(html.includes('Лабораторный опыт 3'), 'Лаб.3');
assert.ok(html.includes('/js/chem8_ch2_widgets.js'), 'виджеты главы 2');
});
test('Chem8.miniPeriodic возвращает API с highlight', () => {
assert.equal(typeof C.miniPeriodic, 'function', 'miniPeriodic реализован');
});
test('Phase 4 — Глава 3 построена + atomShell/shellConfig корректны', () => {
const html = fs.readFileSync(path.join(TB, 'chemistry_8_ch3.html'), 'utf8');
for (let i = 29; i <= 35; i++) assert.ok(html.includes('id="sec-p' + i + '"'), '§' + i + ' секция');
assert.ok(html.includes('id="c-atom"'), 'модель атома §29');
assert.ok(html.includes('id="c-passport"'), 'паспорт §35');
assert.ok(html.includes('/js/chem8_ch3_widgets.js'), 'виджеты главы 3');
assert.deepEqual(C.shellConfig(11), [2, 8, 1], 'Na: 2,8,1');
assert.deepEqual(C.shellConfig(20), [2, 8, 8, 2], 'Ca: 2,8,8,2');
assert.equal(C.nuclide(11, 23).N, 12, '²³Na: 12 нейтронов');
assert.equal(C.zSym(17), 'Cl', 'Z=17 → Cl');
});
test('Phase 5 — Глава 4 построена + bondType корректен', () => {
const html = fs.readFileSync(path.join(TB, 'chemistry_8_ch4.html'), 'utf8');
for (let i = 36; i <= 41; i++) assert.ok(html.includes('id="sec-p' + i + '"'), '§' + i + ' секция');
assert.ok(html.includes('id="c-bond1"'), 'тип связи §37');
assert.ok(html.includes('Лабораторный опыт 4'), 'Лаб.4');
assert.ok(html.includes('/js/chem8_ch4_widgets.js'), 'виджеты главы 4');
assert.equal(C.bondClass('H', 'H').type, 'ковалентная неполярная');
assert.equal(C.bondClass('H', 'Cl').type, 'ковалентная полярная');
assert.equal(C.bondClass('Na', 'Cl').type, 'ионная');
});
test('Phase 6 — Глава 5 построена + oxStates корректен', () => {
const html = fs.readFileSync(path.join(TB, 'chemistry_8_ch5.html'), 'utf8');
for (let i = 42; i <= 45; i++) assert.ok(html.includes('id="sec-p' + i + '"'), '§' + i + ' секция');
assert.ok(html.includes('id="c-ox"'), 'калькулятор с.о. §42');
assert.ok(html.includes('id="c-redox-pick"'), 'электронный баланс §44');
assert.ok(html.includes('/js/chem8_ch5_widgets.js'), 'виджеты главы 5');
assert.equal(C.oxStates('H2SO4').S, 6, 'S в H₂SO₄ = +6');
assert.equal(C.oxStates('KMnO4').Mn, 7, 'Mn в KMnO₄ = +7');
assert.equal(C.oxStates('HNO3').N, 5, 'N в HNO₃ = +5');
});
test('Phase 6 — Глава 6 построена (§46–52 + ПР4 + финал)', () => {
const html = fs.readFileSync(path.join(TB, 'chemistry_8_ch6.html'), 'utf8');
for (let i = 46; i <= 52; i++) assert.ok(html.includes('id="sec-p' + i + '"'), '§' + i + ' секция');
assert.ok(html.includes('id="c-mix"'), 'классификатор смесей §46');
assert.ok(html.includes('id="c-wcalc"'), 'калькулятор w §50');
assert.ok(html.includes('id="c-ccalc"'), 'калькулятор c §51');
assert.ok(html.includes('Практическая работа 4'), 'ПР4');
assert.ok(html.includes('/js/chem8_ch6_widgets.js'), 'виджеты главы 6');
});
test('chem8_engine.js и виджеты — валидный синтаксис', () => {
const eng = fs.readFileSync(path.join(ROOT, 'frontend', 'js', 'chem8_engine.js'), 'utf8');
const wid = fs.readFileSync(path.join(ROOT, 'frontend', 'js', 'chem8_intro_widgets.js'), 'utf8');
assert.doesNotThrow(() => new Function(eng), 'движок парсится');
assert.doesNotThrow(() => new Function(wid), 'виджеты парсятся');
});
test('Phase 1 — ответы босса согласованы с molarMass', () => {
// значения в боссе intro должны совпадать с движком
assert.equal(C.molarMass('H2SO4'), 98); // задача 1
assert.equal(C.molarMass('NaOH'), 40); // задача 2 (M в условии)
assert.ok(Math.abs(3 * 22.4 - 67.2) < 1e-9); // задача 3: V=n·Vm
assert.ok(Math.abs(2 * 6.02 - 12.04) < 1e-9); // задача 4: N=n·N_A
});
test('миграция 041 — родитель chemistry-8 + 7 детей, нет эмоджи', () => {
const sql = fs.readFileSync(
path.join(ROOT, 'backend', 'src', 'db', 'migrations', '041_chemistry8_hub.sql'), 'utf8');
assert.ok(/'chemistry-8'.*NULL/s.test(sql) || sql.includes("'chemistry-8', 'chemistry', 8"), 'родитель');
for (const ch of CHILDREN) {
assert.ok(sql.includes("'" + ch.slug + "'"), 'дитя ' + ch.slug);
}
// запрет эмоджи (правило проекта)
assert.ok(!/[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}]/u.test(sql), 'без эмоджи');
});
+193
View File
@@ -0,0 +1,193 @@
'use strict';
/**
* Integration tests: /api/lab — curriculum links (Phase 5).
* Covers: related (auth), reverse lookup, admin add/delete, validation,
* role-gating, textbook/topic existence checks, enabled-filtering of reverse.
*/
const { describe, it, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { app, db, inject, getToken, cleanup } = require('./setup');
// Mount /api/lab on the shared test app.
app.use('/api/lab', require('../src/routes/lab'));
after(() => cleanup());
/**
* Schema-robust insert: fills every NOT NULL column (without a default) that the
* caller didn't provide with a safe placeholder, then inserts. Returns lastInsertRowid.
* Protects the seed from schema drift (e.g. textbooks.html_path NOT NULL) introduced
* by parallel sessions on this branch.
*/
function seedRow(table, provided) {
const cols = db.prepare(`PRAGMA table_info(${table})`).all();
const colNames = new Set(cols.map(c => c.name));
// Keep ONLY keys that are real columns (drops fields absent in this schema —
// robust to drift, e.g. topics may lack slug/subject_id on some branches).
const row = {};
for (const k of Object.keys(provided)) if (colNames.has(k)) row[k] = provided[k];
// Fill any required (NOT NULL, no default) column the caller didn't provide.
for (const c of cols) {
if (c.pk) continue;
if (c.name in row) continue;
if (c.notnull && c.dflt_value === null) {
row[c.name] = /INT|REAL|NUM/i.test(c.type) ? 0 : '';
}
}
const names = Object.keys(row);
const ph = names.map(() => '?').join(', ');
const info = db.prepare(`INSERT INTO ${table} (${names.join(', ')}) VALUES (${ph})`)
.run(...names.map(n => row[n]));
return info.lastInsertRowid;
}
describe('/api/lab curriculum links', () => {
let adminToken, studentToken, tbSlug, topicId;
before(async () => {
adminToken = (await getToken('admin')).token;
studentToken = (await getToken('student')).token;
// Seed a textbook + topic to link against (schema-robust — fills NOT NULL cols).
tbSlug = 'phys-test';
seedRow('textbooks', { slug: tbSlug, title: 'Физика тест', subject: 'physics', grade: 9, is_active: 1 });
const subjId = seedRow('subjects', { name: 'LinkTest Subj', slug: 'linktest-subj' });
topicId = seedRow('topics', { subject_id: subjId, name: 'Колебания тест', slug: 'kolebaniya-test' });
});
it('GET /related requires auth (401)', async () => {
const res = await inject('GET', '/api/lab/sims/pendulum/related', null, null);
assert.equal(res.status, 401, `got ${res.status}`);
});
it('GET /related returns empty link buckets for a sim with no links', async () => {
const res = await inject('GET', '/api/lab/sims/pendulum/related', null, studentToken);
assert.equal(res.status, 200, `got ${res.status}`);
assert.equal(res.body.sim.id, 'pendulum');
assert.deepEqual(res.body.links.textbook, []);
assert.deepEqual(res.body.links.topic, []);
});
it('POST /links is admin-only (student → 403)', async () => {
const res = await inject('POST', '/api/lab/sims/pendulum/links',
{ kind: 'textbook', ref_id: tbSlug }, studentToken);
assert.equal(res.status, 403, `got ${res.status}`);
});
it('admin can add a textbook link; label resolved from textbooks', async () => {
const res = await inject('POST', '/api/lab/sims/pendulum/links',
{ kind: 'textbook', ref_id: tbSlug }, adminToken);
assert.equal(res.status, 200, `got ${res.status}`);
assert.equal(res.body.link.kind, 'textbook');
assert.equal(res.body.link.ref_id, tbSlug);
assert.equal(res.body.link.label, 'Физика тест', 'label resolved from textbook title');
assert.ok(res.body.link.href.includes(tbSlug), 'href points to textbook');
});
it('related now shows the textbook link', async () => {
const res = await inject('GET', '/api/lab/sims/pendulum/related', null, studentToken);
assert.equal(res.status, 200);
assert.equal(res.body.links.textbook.length, 1);
assert.equal(res.body.links.textbook[0].ref_id, tbSlug);
});
it('admin can add a topic link; label resolved from topics', async () => {
const res = await inject('POST', '/api/lab/sims/pendulum/links',
{ kind: 'topic', ref_id: String(topicId) }, adminToken);
assert.equal(res.status, 200, `got ${res.status}`);
assert.equal(res.body.link.label, 'Колебания тест');
});
it('duplicate link → 409', async () => {
const res = await inject('POST', '/api/lab/sims/pendulum/links',
{ kind: 'textbook', ref_id: tbSlug }, adminToken);
assert.equal(res.status, 409, `got ${res.status}`);
});
it('validation: bad kind → 400', async () => {
const res = await inject('POST', '/api/lab/sims/pendulum/links',
{ kind: 'nope', ref_id: 'x' }, adminToken);
assert.equal(res.status, 400);
});
it('validation: missing ref_id → 400', async () => {
const res = await inject('POST', '/api/lab/sims/pendulum/links',
{ kind: 'textbook' }, adminToken);
assert.equal(res.status, 400);
});
it('validation: unknown textbook → 404', async () => {
const res = await inject('POST', '/api/lab/sims/pendulum/links',
{ kind: 'textbook', ref_id: 'ghost-book' }, adminToken);
assert.equal(res.status, 404);
});
it('validation: unknown topic → 404', async () => {
const res = await inject('POST', '/api/lab/sims/pendulum/links',
{ kind: 'topic', ref_id: '999999' }, adminToken);
assert.equal(res.status, 404);
});
it('POST link to unknown sim → 404', async () => {
const res = await inject('POST', '/api/lab/sims/ghostsim/links',
{ kind: 'textbook', ref_id: tbSlug }, adminToken);
assert.equal(res.status, 404);
});
it('reverse lookup: GET /links?kind=textbook&ref_id= returns linked enabled sims', async () => {
const res = await inject('GET',
`/api/lab/links?kind=textbook&ref_id=${encodeURIComponent(tbSlug)}`, null, studentToken);
assert.equal(res.status, 200, `got ${res.status}`);
assert.ok(res.body.sims.some(s => s.id === 'pendulum'), 'pendulum in reverse lookup');
});
it('reverse lookup excludes disabled sims', async () => {
await inject('PATCH', '/api/lab/sims/pendulum', { enabled: false }, adminToken);
const res = await inject('GET',
`/api/lab/links?kind=textbook&ref_id=${encodeURIComponent(tbSlug)}`, null, studentToken);
assert.ok(!res.body.sims.some(s => s.id === 'pendulum'), 'disabled pendulum excluded');
await inject('PATCH', '/api/lab/sims/pendulum', { enabled: true }, adminToken); // restore
});
it('batch reverse lookup: GET /links/all?kind=textbook groups by ref_id', async () => {
const res = await inject('GET', '/api/lab/links/all?kind=textbook', null, studentToken);
assert.equal(res.status, 200, `got ${res.status}`);
assert.ok(res.body.byRef, 'byRef present');
assert.ok(Array.isArray(res.body.byRef[tbSlug]), `byRef[${tbSlug}] is array`);
assert.ok(res.body.byRef[tbSlug].some(s => s.id === 'pendulum'), 'pendulum grouped under tbSlug');
});
it('batch reverse lookup: bad kind → 400', async () => {
const res = await inject('GET', '/api/lab/links/all?kind=nope', null, studentToken);
assert.equal(res.status, 400);
});
it('batch reverse lookup requires auth (401)', async () => {
const res = await inject('GET', '/api/lab/links/all?kind=textbook', null, null);
assert.equal(res.status, 401);
});
it('reverse lookup: bad kind → 400', async () => {
const res = await inject('GET', '/api/lab/links?kind=nope&ref_id=x', null, studentToken);
assert.equal(res.status, 400);
});
it('admin can delete a link; related reflects removal', async () => {
// find the textbook link id
const rel = await inject('GET', '/api/lab/sims/pendulum/related', null, adminToken);
const linkId = rel.body.links.textbook[0].id;
const del = await inject('DELETE', `/api/lab/sims/pendulum/links/${linkId}`, null, adminToken);
assert.equal(del.status, 200, `got ${del.status}`);
const rel2 = await inject('GET', '/api/lab/sims/pendulum/related', null, adminToken);
assert.equal(rel2.body.links.textbook.length, 0, 'textbook link gone');
});
it('delete unknown link → 404', async () => {
const res = await inject('DELETE', '/api/lab/sims/pendulum/links/999999', null, adminToken);
assert.equal(res.status, 404);
});
it('delete is admin-only (student → 403)', async () => {
const res = await inject('DELETE', '/api/lab/sims/pendulum/links/1', null, studentToken);
assert.equal(res.status, 403);
});
});
+122
View File
@@ -0,0 +1,122 @@
'use strict';
/**
* Integration tests: /api/lab/sims — catalog from DB + admin overrides.
* Covers: seeded catalog, auth, role-gating, enabled toggle (+legacy mirror),
* featured/tags/subject/grade patch, reorder, validation.
*/
const { describe, it, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { app, db, inject, getToken, cleanup } = require('./setup');
// Mount /api/lab on the shared test app (setup builds its own app without it).
app.use('/api/lab', require('../src/routes/lab'));
after(() => cleanup());
describe('/api/lab/sims', () => {
let adminToken, studentToken;
before(async () => {
adminToken = (await getToken('admin')).token;
studentToken = (await getToken('student')).token;
});
it('GET /api/lab/sims requires auth (401 without token)', async () => {
const res = await inject('GET', '/api/lab/sims', null, null);
assert.equal(res.status, 401, `got ${res.status}`);
});
it('GET /api/lab/sims returns seeded catalog (40 sims) for a student', async () => {
const res = await inject('GET', '/api/lab/sims', null, studentToken);
assert.equal(res.status, 200, `got ${res.status}`);
assert.equal(res.body.module_disabled, false);
assert.ok(Array.isArray(res.body.sims), 'sims is array');
assert.equal(res.body.sims.length, 40, `expected 40 sims, got ${res.body.sims.length}`);
const pend = res.body.sims.find(s => s.id === 'pendulum');
assert.ok(pend, 'pendulum present');
assert.equal(pend.cat, 'phys');
assert.equal(pend.enabled, true);
assert.deepEqual(pend.tags, []);
});
it('catalog is ordered by sort_order (graph first, angrybirds last)', async () => {
const res = await inject('GET', '/api/lab/sims', null, studentToken);
assert.equal(res.body.sims[0].id, 'graph');
assert.equal(res.body.sims[res.body.sims.length - 1].id, 'angrybirds');
});
it('PATCH /api/lab/sims/:id is admin-only (student → 403)', async () => {
const res = await inject('PATCH', '/api/lab/sims/pendulum', { featured: true }, studentToken);
assert.equal(res.status, 403, `got ${res.status}`);
});
it('admin can disable a sim; it reflects in GET and in legacy sim_disabled_ids', async () => {
const res = await inject('PATCH', '/api/lab/sims/waves', { enabled: false }, adminToken);
assert.equal(res.status, 200, `got ${res.status}`);
assert.equal(res.body.sim.enabled, false);
const get = await inject('GET', '/api/lab/sims', null, adminToken);
const waves = get.body.sims.find(s => s.id === 'waves');
assert.equal(waves.enabled, false, 'waves disabled in catalog');
const legacy = JSON.parse(
db.prepare("SELECT value FROM app_settings WHERE key='sim_disabled_ids'").get().value
);
assert.ok(legacy.includes('waves'), 'waves in legacy sim_disabled_ids');
await inject('PATCH', '/api/lab/sims/waves', { enabled: true }, adminToken);
const legacy2 = JSON.parse(
db.prepare("SELECT value FROM app_settings WHERE key='sim_disabled_ids'").get().value
);
assert.ok(!legacy2.includes('waves'), 'waves removed from legacy after enable');
});
it('admin can set featured, tags, subject, grade', async () => {
const res = await inject('PATCH', '/api/lab/sims/pendulum',
{ featured: true, tags: ['колебания', 'механика'], subject: 'physics', grade: 9 }, adminToken);
assert.equal(res.status, 200);
assert.equal(res.body.sim.featured, true);
assert.deepEqual(res.body.sim.tags, ['колебания', 'механика']);
assert.equal(res.body.sim.subject, 'physics');
assert.equal(res.body.sim.grade, 9);
});
it('PATCH rejects bad grade and bad category and non-array tags', async () => {
const g = await inject('PATCH', '/api/lab/sims/pendulum', { grade: 99 }, adminToken);
assert.equal(g.status, 400, 'bad grade rejected');
const c = await inject('PATCH', '/api/lab/sims/pendulum', { cat: 'nope' }, adminToken);
assert.equal(c.status, 400, 'bad cat rejected');
const t = await inject('PATCH', '/api/lab/sims/pendulum', { tags: 'notarray' }, adminToken);
assert.equal(t.status, 400, 'non-array tags rejected');
});
it('PATCH unknown sim → 404', async () => {
const res = await inject('PATCH', '/api/lab/sims/nonexistent', { featured: true }, adminToken);
assert.equal(res.status, 404, `got ${res.status}`);
});
it('POST /api/lab/sims/reorder updates sort order (admin)', async () => {
const get = await inject('GET', '/api/lab/sims', null, adminToken);
const ids = get.body.sims.map(s => s.id);
const reordered = ['angrybirds', 'graph', ...ids.filter(id => id !== 'angrybirds' && id !== 'graph')];
const res = await inject('POST', '/api/lab/sims/reorder', { order: reordered }, adminToken);
assert.equal(res.status, 200, `got ${res.status}`);
assert.equal(res.body.count, 40);
const get2 = await inject('GET', '/api/lab/sims', null, adminToken);
assert.equal(get2.body.sims[0].id, 'angrybirds', 'angrybirds now first');
assert.equal(get2.body.sims[1].id, 'graph', 'graph now second');
});
it('reorder rejects unknown id and empty order', async () => {
const bad = await inject('POST', '/api/lab/sims/reorder', { order: ['ghost'] }, adminToken);
assert.equal(bad.status, 400, 'unknown id rejected');
const empty = await inject('POST', '/api/lab/sims/reorder', { order: [] }, adminToken);
assert.equal(empty.status, 400, 'empty order rejected');
});
it('reorder is admin-only (student → 403)', async () => {
const res = await inject('POST', '/api/lab/sims/reorder', { order: ['graph'] }, studentToken);
assert.equal(res.status, 403, `got ${res.status}`);
});
});