'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', '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(' { 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), 'без эмоджи'); });