feat(chemistry-8): Phase 1 — раздел «Количественные понятия» (§1–9 + ПР1)

Полноценная интерактивная страница chemistry_8_intro.html (9 § + ПР1 + босс):
- §1 карта элементов (Z, название, Ar), §2 калькулятор Mr по формуле
- §3 «порция вещества» n⇒N,m, §4 счётчик частиц N=n·N_A, §5 M+молярный объём
- §6 звёздный виджет: интерактивный треугольник n–m–M
- §7 универсальный калькулятор газа (m–n–V–N), §8 балансировщик уравнений
- §9 пошаговый решатель по уравнению; босс раздела (4 задачи) + ачивка «Счёт в химии»
- прогресс/XP через /api/textbooks/chemistry-8-intro/progress, scrollspy, тема

chem8_svg.js: реализованы движки — molarMass (школьные Ar: Mr(H2O)=18),
elementCounts, moleTriangle, equationBalancer (+ fmt, arOf).

Фикс порядка загрузки: инициализация обёрнута в DOMContentLoaded (defer-скрипты
готовы к этому моменту). Генератор каркасов получил skip-if-exists (--force для перезаписи).

Тесты: chemistry8.test.js (14) + chemistry8-dom.test.js (jsdom-смоук виджетов, 3) — 17/17.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
This commit is contained in:
Maxim Dolgolyov
2026-05-30 14:36:31 +03:00
parent d6036fbb8e
commit 6ea140af54
5 changed files with 1082 additions and 76 deletions
+41 -1
View File
@@ -49,13 +49,34 @@ test('Chem8.chemEq — обратимая реакция и осадок', () =>
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 ['testTube', 'moleTriangle', 'solubilityTable', 'oxStateCalc', 'geneticMap']) {
for (const fn of ['testTube', 'solubilityTable', 'oxStateCalc', 'geneticMap']) {
assert.equal(typeof C[fn], 'function', fn + ' определён');
assert.equal(C[fn]({}), null, fn + ' заглушка возвращает null');
}
});
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 },
@@ -90,6 +111,25 @@ test('каждая глава существует и задаёт свой _TB_
}
});
test('Phase 1 — раздел intro наполнен (9 § + ПР1 + босс)', () => {
const html = fs.readFileSync(path.join(TB, 'chemistry_8_intro.html'), 'utf8');
for (let i = 1; i <= 9; i++) assert.ok(html.includes('id="p' + i + '"'), '§' + i + ' секция');
assert.ok(html.includes('id="pr1"'), 'ПР1');
assert.ok(html.includes('id="boss"'), 'босс раздела');
assert.ok(html.includes('id="mt-mount"'), 'треугольник n–m–M');
assert.ok(html.includes('id="bal-mount"'), 'балансировщик');
assert.ok(html.includes("READ_IDS = ['p1','p2','p3','p4','p5','p6','p7','p8','p9']"), '9 читаемых § для прогресса');
assert.ok(!html.includes('Раздел в разработке'), 'баннер-заглушка убран');
});
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');