@
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:
@@ -287,11 +287,19 @@ const _TB_SLUG = '${ch.slug}';
|
||||
`;
|
||||
}
|
||||
|
||||
let count = 0;
|
||||
// --force перезапишет уже существующие файлы; по умолчанию — пропускаем
|
||||
// готовые (наполненные в фазах) страницы, чтобы не затереть контент.
|
||||
const FORCE = process.argv.includes('--force');
|
||||
let count = 0, skipped = 0;
|
||||
for (const ch of CHAPTERS) {
|
||||
const html = pageHtml(ch);
|
||||
fs.writeFileSync(path.join(OUT, ch.file), html, 'utf8');
|
||||
const target = path.join(OUT, ch.file);
|
||||
if (!FORCE && fs.existsSync(target)) {
|
||||
skipped++;
|
||||
console.log('skip ', ch.file, '(уже существует — наполнен в фазе)');
|
||||
continue;
|
||||
}
|
||||
fs.writeFileSync(target, pageHtml(ch), 'utf8');
|
||||
count++;
|
||||
console.log('written', ch.file, '(' + ch.items.filter(i => i.t).length + ' §)');
|
||||
}
|
||||
console.log('done:', count, 'chapter skeletons');
|
||||
console.log('done:', count, 'written,', skipped, 'skipped');
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
'use strict';
|
||||
/*
|
||||
* jsdom-смоук виджетов chem8_svg.js: реальная отрисовка в DOM, ввод, проверка.
|
||||
* Ловит рантайм-ошибки DOM-манипуляций, которые не видны в чистых юнит-тестах.
|
||||
*/
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { JSDOM } = require('jsdom');
|
||||
|
||||
const SRC = fs.readFileSync(
|
||||
path.join(__dirname, '..', '..', 'frontend', 'js', 'chem8_svg.js'), 'utf8');
|
||||
|
||||
function mkDom() {
|
||||
const dom = new JSDOM('<!DOCTYPE html><body><div id="m"></div><div id="b"></div></body>');
|
||||
// выполняем модуль так, что его `window` === jsdom-окно
|
||||
new Function('window', SRC)(dom.window);
|
||||
return { dom, C: dom.window.Chem8, doc: dom.window.document };
|
||||
}
|
||||
|
||||
function fire(el, type) {
|
||||
el.dispatchEvent(new el.ownerDocument.defaultView.Event(type, { bubbles: true }));
|
||||
}
|
||||
|
||||
test('moleTriangle монтируется и считает m = n·M', () => {
|
||||
const { C, doc } = mkDom();
|
||||
const api = C.moleTriangle(doc.getElementById('m'), {});
|
||||
assert.ok(api && api.el, 'виджет смонтирован');
|
||||
const inputs = doc.querySelectorAll('#m input[data-k]');
|
||||
assert.equal(inputs.length, 3, '3 поля');
|
||||
const byKey = {};
|
||||
inputs.forEach(i => { byKey[i.getAttribute('data-k')] = i; });
|
||||
// вводим n=2, затем M=18 → ожидаем m=36
|
||||
byKey.n.value = '2'; fire(byKey.n, 'input');
|
||||
byKey.M.value = '18'; fire(byKey.M, 'input');
|
||||
const out = doc.querySelector('#m [data-out]');
|
||||
assert.ok(/36/.test(out.textContent), 'm = 36 вычислено: ' + out.textContent);
|
||||
});
|
||||
|
||||
test('equationBalancer: неверные коэффициенты → дисбаланс, верные → баланс', () => {
|
||||
const { C, doc } = mkDom();
|
||||
const api = C.equationBalancer(doc.getElementById('b'),
|
||||
{ skeleton: 'H2 + O2 -> H2O', solution: [2, 1, 2] });
|
||||
assert.ok(api && api.check, 'виджет смонтирован');
|
||||
// по умолчанию все коэффициенты = 1 → не сбалансировано
|
||||
assert.equal(api.check(), false, '1·H2 + 1·O2 -> 1·H2O не сбалансировано');
|
||||
const out = doc.querySelector('#b [data-out]');
|
||||
assert.ok(out.className.includes('bad'), 'подсветка дисбаланса');
|
||||
// применяем решение через кнопку
|
||||
doc.querySelector('#b [data-solve]').dispatchEvent(
|
||||
new doc.defaultView.Event('click', { bubbles: true }));
|
||||
assert.ok(out.className.includes('ok'), 'после решения — сбалансировано: ' + out.className);
|
||||
});
|
||||
|
||||
test('equationBalancer считает атомы для сложной реакции', () => {
|
||||
const { C, doc } = mkDom();
|
||||
const api = C.equationBalancer(doc.getElementById('b'),
|
||||
{ skeleton: 'Al + HCl -> AlCl3 + H2', solution: [2, 6, 2, 3] });
|
||||
const coefs = doc.querySelectorAll('#b .ceqb-coef');
|
||||
[2, 6, 2, 3].forEach((v, i) => { coefs[i].value = String(v); });
|
||||
assert.equal(api.check(), true, '2Al + 6HCl -> 2AlCl3 + 3H2 сбалансировано');
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user