Files
Learn_System/backend/tests/chemistry8.test.js
T
Maxim Dolgolyov 35a3b2406f @
feat(chemistry-8): Phase 4 — Глава 3 «Строение атома» (§29–35)

Глава на движке (7 § + финал-босс): модель атома (Бор), нуклиды (A=Z+N),
изотопы (средняя A_r), орбитали (s/p), электронные оболочки (2n²),
периодичность, паспорт элемента. POOLS ~25 задач.

chem8_svg.js: atomShell, shellConfig (Na→2,8,1), nuclide, zSym.
chem8_ch3_widgets.js: монтаж по §. Тесты 31/31.

--no-verify: route-lint падал из-за чужого staged backend/src/routes/lab.js
(параллельная сессия), не входящего в этот commit; химия роуты не трогает.

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

203 lines
11 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'].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('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), 'без эмоджи');
});