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:
@@ -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: '§ 1–9', 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: '§ 10–23', 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: '§ 24–28', 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: '§ 29–35', 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: '§ 36–41', 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: '§ 42–45', 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: '§ 46–52', 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 => ({ '&':'&', '<':'<', '>':'>' }[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)} · ${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 класс» · ${esc(ch.kicker)} · 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 (Важнейшие классы соединений, §§10–23) → chemistry_8_ch1.html
|
||||
-- chemistry-8-ch2 (Периодический закон и ПСХЭ, §§24–28) → 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 (ОВР, §§42–45) → 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);
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
|
||||
@@ -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 сбалансировано');
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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), 'без эмоджи');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user