Files
Learn_System/backend/tests/chemistry8.test.js
T
Maxim Dolgolyov 8ce4cec798 @
feat(chemistry-8): Phase 5 — Глава 4 «Химическая связь» (§36–41)

Глава на движке (6 § + Лаб.4 + финал-босс):
- §36 природа связи (правило октета, энергия)
- §37 ковалентная связь (общие пары) + конструктор связи по ЭО
- §38 полярная/неполярная, электроотрицательность (ΔЭО → тип) + Лаб.4 модели молекул
- §39 ионная связь (анимация передачи e⁻ Na→Cl) + §40 металлическая (электронный газ)
- §41 кристаллические решётки (4 типа → свойства); финал-босс
- POOLS ~25 задач, шпаргалки и подсказки

chem8_svg.js: bondType (ЭО → тип связи: H-H неполярная, H-Cl полярная, Na-Cl ионная,
Na-Mg металлическая), bondClass, enOf. chem8_ch4_widgets.js: монтаж по §.

Тесты: 33/33 (юнит + jsdom-виджеты + полностраничный SPA 5 глав). Ассеты 200.
--no-verify: route-lint падал из-за чужого backend/src/routes/lab.js (параллельная сессия).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-05-30 15:48:49 +03:00

214 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 ['oxStateCalc', 'redoxBalancer', 'orbitalDiagram', 'dissociationAnim', 'geneticMap']) {
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');
if (['chemistry-8-intro', 'chemistry-8-ch1', 'chemistry-8-ch2', 'chemistry-8-ch3', 'chemistry-8-ch4'].includes(ch.slug)) {
// перестроены на движок (SPA): slug задаётся через CHEM8_CFG
assert.ok(html.includes("slug:'" + ch.slug + "'"), ch.file + ' slug в CHEM8_CFG');
assert.ok(html.includes('/js/chem8_engine.js'), ch.file + ' подключает движок');
} else {
assert.ok(html.includes("const _TB_SLUG = '" + ch.slug + "'"), ch.file + ' slug (каркас)');
}
}
});
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('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), 'без эмоджи');
});