Files
Learn_System/backend/tests/chemistry8.test.js
T
Maxim Dolgolyov d8508baf8d @
feat(chemistry-8): Phase 2 — Глава 1 «Важнейшие классы неорг. соединений» (§10–23)

Полная глава на движке (14 § + 2 лаб. опыта + 2 практические работы + финал-босс):
- §10–12 оксиды (классификатор, свойства, получение)
- §13–15 кислоты (классификатор, ряд активности, индикаторы, получение)
- §16–18 основания (классификатор, фенолфталеин, Лаб.1 Cu(OH)₂↓, ПР2 нейтрализация)
- §19–21 соли (таблица растворимости, РИО, соль+металл, Лаб.2, способы)
- §22 генетическая связь классов + ПР3; §23 расчётный решатель; финал-босс (6 задач)
- POOLS: ~45 задач (MCQ + числовые), шпаргалки и подсказки по каждому §

chem8_svg.js: реализованы 5 хим-виджетов (были заглушки) — testTube (осадок/газ),
indicatorScale (лакмус/фенолфталеин/метилоранж + pH), classifier (клик-DnD),
solubilityTable (катион×анион), activitySeries (ряд активности металлов).
chem8-textbook.css: стили виджетов. chem8_ch1_widgets.js: монтаж по §.

Тесты: 24/24 (юнит + jsdom-виджеты + полностраничный SPA intro и ch1 — para-selector,
активный §, монтаж флагманов, тренажёр, без ошибок). Ассеты 200.

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

178 lines
9.3 KiB
JavaScript

'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', 'miniPeriodic', '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 (ch.slug === 'chemistry-8-intro' || ch.slug === 'chemistry-8-ch1') {
// перестроены на движок (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('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), 'без эмоджи');
});