Files
Learn_System/backend/tests/math6-page.test.js
T
Maxim Dolgolyov 21c18ce477 feat(math6): полировка Гл.6 §3 — перетаскиваемый треугольник
Math6Anim.triangleDrag (SVG): тащишь вершины A/B/C — тип пересчитывается
вживую по сторонам и по углам, штрихи равных сторон + метка прямого угла.
Блок «Песочница» перед интерактивами §3. Тесты math6: 20/20.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 22:13:01 +03:00

321 lines
20 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 jsdom-каркас «Математика 6»: хаб и 6 глав выполняются на движке
* math6_engine.js без ошибок скриптов; para-selector строится с нужным числом
* карточек; активен первый §; движок рендерит секции и заглушку/контент с кнопкой
* прочтения. Содержание §§ наполняется по главам — здесь проверяется каркас.
*/
const test = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const { JSDOM, VirtualConsole } = require('jsdom');
const ROOT = path.join(__dirname, '..', '..');
const readF = p => fs.readFileSync(path.join(ROOT, p), 'utf8');
const wait = ms => new Promise(r => setTimeout(r, ms));
/* Инлайним внешние скрипты (CDN убираем, api/xp заменяем заглушками). */
function buildPage(file) {
let html = readF('frontend/textbooks/' + file);
const inl = {
'/js/math6_svg.js': readF('frontend/js/math6_svg.js'),
'/js/math6_anim.js': readF('frontend/js/math6_anim.js'),
'/js/math6_engine.js': readF('frontend/js/math6_engine.js')
};
html = html
.replace(/<script defer src="https:\/\/cdn[^"]*"[^>]*><\/script>/g, '')
.replace(/<script src="\/js\/api\.js" defer><\/script>/, '<script>window.renderMathInElement=function(){};</script>')
.replace(/<script src="\/js\/xp\.js" defer><\/script>/, '');
Object.keys(inl).forEach(src => {
html = html.replace(new RegExp('<script src="' + src + '" defer><\\/script>'), () => '<script>\n' + inl[src] + '\n</script>');
});
return html;
}
async function loadDom(file) {
const errors = [];
const vc = new VirtualConsole();
vc.on('jsdomError', e => errors.push(e.message));
const dom = new JSDOM(buildPage(file), {
runScripts: 'dangerously', pretendToBeVisual: true, virtualConsole: vc, url: 'http://localhost/',
beforeParse(w) { w.scrollTo = function () {}; }
});
await wait(160);
return { dom, errors, doc: dom.window.document };
}
const CHAPTERS = [
{ file: 'math_6_ch1.html', cards: 12 },
{ file: 'math_6_ch2.html', cards: 9 },
{ file: 'math_6_ch3.html', cards: 5 },
{ file: 'math_6_ch4.html', cards: 11 },
{ file: 'math_6_ch5.html', cards: 5 },
{ file: 'math_6_ch6.html', cards: 6 }
];
test('engine: init() вызывается ПОСЛЕ экспортов (guard от sync-defer бага makeCard)', () => {
const src = readF('frontend/js/math6_engine.js');
const exportIdx = src.indexOf('window.makeCard = makeCard');
const initCallIdx = src.lastIndexOf('else init();');
assert.ok(exportIdx > 0, 'есть экспорт window.makeCard');
assert.ok(initCallIdx > exportIdx, 'else init() должен идти ПОСЛЕ window.makeCard = makeCard (иначе билдеры упадут с ReferenceError при defer-старте)');
});
for (const ch of CHAPTERS) {
test(`${ch.file}: SPA без ошибок, ${ch.cards} карточек, активен § 1`, async () => {
const { doc, errors } = await loadDom(ch.file);
assert.deepEqual(errors, [], 'нет ошибок: ' + errors.join(' | '));
assert.equal(doc.querySelectorAll('#psel-grid .psel-card').length, ch.cards, ch.cards + ' карточек');
const active = doc.querySelector('.sec.active');
assert.ok(active && active.id === 'sec-p1', 'активен sec-p1');
const body = doc.querySelector('#p1-body');
assert.ok(body && body.children.length > 0, 'тело § 1 заполнено');
assert.ok(doc.querySelector('#p1-body [data-read]'), 'кнопка прочтения § 1');
/* финал помечен и присутствует */
assert.ok(doc.querySelector('#psel-grid .psel-card.final'), 'есть карточка финала');
});
}
test('ch2: проценты и пропорции — интерактивы + финал', async () => {
const { doc, errors } = await loadDom('math_6_ch2.html');
const win = doc.defaultView;
assert.ok(doc.querySelector('#p1-fig svg rect') && doc.querySelector('#p1-q'), 'сетка 100 и конвертер §1');
win.goTo('p2'); await wait(80);
assert.ok(doc.querySelectorAll('#p2-iv1 [data-t]').length === 3 && doc.querySelector('#p2-cq'), 'типы задач §2');
win.goTo('p3'); await wait(80);
assert.ok(doc.querySelector('#p3-q') && doc.querySelectorAll('#p3-iv2 [data-v]').length === 2, 'пропорция §3');
win.goTo('p7'); await wait(80);
assert.ok(doc.querySelector('#p7-fig canvas') && doc.querySelector('#p7-pick [data-k]'), 'круговая диаграмма §7');
win.goTo('final'); await wait(80);
assert.ok(doc.querySelector('#fin-go'), 'арена боссов §2');
win.bumpProgress('final', 100); await wait(20);
assert.ok(win.M6STATE.achievements.has('ch2_done'), 'достижение «Глава 2 пройдена»');
assert.deepEqual(errors, [], 'нет ошибок: ' + errors.join(' | '));
});
test('ch3: множество — интерактивы + финал', async () => {
const { doc, errors } = await loadDom('math_6_ch3.html');
const win = doc.defaultView;
assert.ok(doc.querySelectorAll('#p1-iv1 [data-v]').length === 2 && doc.querySelector('#p1-cq'), '∈/∉ и счёт §1');
win.goTo('p2'); await wait(80);
assert.ok(doc.querySelector('#p2-opts') && doc.querySelectorAll('#p2-iv2 [data-v]').length === 2, 'способы задания §2');
win.goTo('p3'); await wait(80);
assert.ok(doc.querySelector('#p3-fig svg circle') && doc.querySelectorAll('#p3-iv1 [data-op]').length === 2, 'круги Эйлера/операции §3');
win.goTo('p4'); await wait(80);
assert.ok(doc.querySelector('#p4-fig svg') && doc.querySelector('#p4-fq'), 'задача с кругами Эйлера §4');
win.goTo('final'); await wait(80);
assert.ok(doc.querySelector('#fin-go'), 'арена боссов §3');
win.bumpProgress('final', 100); await wait(20);
assert.ok(win.M6STATE.achievements.has('ch3_done'), 'достижение «Глава 3 пройдена»');
assert.deepEqual(errors, [], 'нет ошибок: ' + errors.join(' | '));
});
test('ch4: рациональные числа — интерактивы + финал', async () => {
const { doc, errors } = await loadDom('math_6_ch4.html');
const win = doc.defaultView;
assert.ok(doc.querySelector('#p1-fig svg') && doc.querySelectorAll('#p1-iv2 [data-v]').length === 3, 'координатная прямая §1');
win.goTo('p2'); await wait(80);
assert.ok(doc.querySelector('#p2-q') && doc.querySelector('#p2-oq'), 'модуль и противоположные §2');
win.goTo('p4'); await wait(80);
assert.ok(doc.querySelector('#p4-asl') && doc.querySelector('#p4-fig svg'), 'сложение на прямой §4');
win.goTo('p7'); await wait(80);
assert.ok(doc.querySelectorAll('#p7-iv1 [data-s]').length === 4 && doc.querySelector('#p7-q'), 'знаки умножения §7');
win.goTo('p9'); await wait(80);
assert.ok(doc.querySelector('#p9-q'), 'порядок действий §9');
win.goTo('final'); await wait(80);
assert.ok(doc.querySelector('#fin-go'), 'арена боссов §4');
win.bumpProgress('final', 100); await wait(20);
assert.ok(win.M6STATE.achievements.has('ch4_done'), 'достижение «Глава 4 пройдена»');
assert.deepEqual(errors, [], 'нет ошибок: ' + errors.join(' | '));
});
test('ch5: координатная плоскость — интерактивы §1–§3 + финал', async () => {
const { doc, errors } = await loadDom('math_6_ch5.html');
const win = doc.defaultView;
assert.ok(doc.querySelector('#p1-fig svg'), 'плоскость с точкой §1');
assert.ok(doc.querySelectorAll('#p1-iv2 [data-q]').length === 4, 'кнопки четвертей §1');
win.goTo('p2'); await wait(80);
assert.ok(doc.querySelector('#p2-fig svg polyline'), 'график процесса §2');
win.goTo('p3'); await wait(80);
assert.ok(doc.querySelector('#p3-k'), 'слайдер k §3');
assert.ok(doc.querySelector('#p3-fig svg path'), 'график y=kx §3');
win.goTo('final'); await wait(80);
assert.ok(doc.querySelector('#fin-go'), 'арена боссов §5');
win.bumpProgress('final', 100); await wait(20);
assert.ok(win.M6STATE.achievements.has('ch5_done'), 'достижение «Глава 5 пройдена»');
assert.deepEqual(errors, [], 'нет ошибок: ' + errors.join(' | '));
});
test('ch6: наглядная геометрия — интерактивы §1–§5 + финал', async () => {
const { doc, errors } = await loadDom('math_6_ch6.html');
const win = doc.defaultView;
assert.ok(doc.querySelector('#p1-fig svg') && doc.querySelector('#p1-q'), 'тела §1');
win.goTo('p2'); await wait(80);
assert.ok(doc.querySelector('#p2-r') && doc.querySelector('#p2-fig svg circle'), 'окружность §2');
assert.ok(doc.querySelector('#p2-out').textContent.indexOf('=') >= 0, 'формулы C, S §2');
win.goTo('p3'); await wait(80);
assert.ok(doc.querySelector('#p3-fig svg polygon'), 'треугольник §3');
assert.ok(doc.querySelectorAll('#p3-iv1 [data-v]').length === 3, 'виды по сторонам §3');
win.goTo('p4'); await wait(80);
assert.ok(doc.querySelector('#p4-fig svg'), 'плоскость симметрии §4');
win.goTo('p5'); await wait(80);
assert.ok(doc.querySelector('#p5-fig svg'), 'плоскость симметрии §5');
win.goTo('final'); await wait(80);
assert.ok(doc.querySelector('#fin-go'), 'арена боссов §6');
win.bumpProgress('final', 100); await wait(20);
assert.ok(win.M6STATE.achievements.has('ch6_done'), 'достижение «Глава 6 пройдена»');
assert.deepEqual(errors, [], 'нет ошибок: ' + errors.join(' | '));
});
test('анимации: canvas-демо монтируются (headless-safe)', async () => {
// Глава 6 §2: колесо + заметание площади
const r6 = await loadDom('math_6_ch6.html');
r6.doc.defaultView.goTo('p3'); await wait(100);
assert.ok(r6.doc.querySelector('#p3-tri svg polygon'), 'svg «перетаскиваемый треугольник» §6.3');
r6.doc.defaultView.goTo('p2'); await wait(100);
assert.ok(r6.doc.querySelector('#p2-roll canvas'), 'canvas «колесо» §6.2');
assert.ok(r6.doc.querySelector('#p2-sweep canvas'), 'canvas «заметание площади» §6.2');
r6.doc.defaultView.goTo('p4'); await wait(100);
assert.ok(r6.doc.querySelector('#p4-symfig canvas'), 'canvas «центральная симметрия» §6.4');
r6.doc.defaultView.goTo('p5'); await wait(100);
assert.ok(r6.doc.querySelector('#p5-symfig canvas'), 'canvas «осевая симметрия» §6.5');
assert.deepEqual(r6.errors, [], 'ch6 без ошибок: ' + r6.errors.join(' | '));
// Глава 1 §6: площадная модель умножения
const r1 = await loadDom('math_6_ch1.html');
r1.doc.defaultView.goTo('p6'); await wait(100);
assert.ok(r1.doc.querySelector('#p6-area canvas'), 'canvas «площадная модель» §1.6');
assert.deepEqual(r1.errors, [], 'ch1 без ошибок: ' + r1.errors.join(' | '));
// Глава 4 §4: прыжки по числовой прямой
const r4 = await loadDom('math_6_ch4.html');
r4.doc.defaultView.goTo('p4'); await wait(100);
assert.ok(r4.doc.querySelector('#p4-walk canvas'), 'canvas «прыжки по прямой» §4.4');
r4.doc.defaultView.goTo('p1'); await wait(100);
assert.ok(r4.doc.querySelector('#p1-therm-fig canvas'), 'canvas «термометр» §4.1');
r4.doc.defaultView.goTo('p7'); await wait(100);
assert.ok(r4.doc.querySelector('#p7-jumpfig canvas'), 'canvas «умножение-прыжки» §4.7');
assert.deepEqual(r4.errors, [], 'ch4 без ошибок: ' + r4.errors.join(' | '));
// Глава 5 §2: машинка + график
const r5 = await loadDom('math_6_ch5.html');
r5.doc.defaultView.goTo('p2'); await wait(100);
assert.ok(r5.doc.querySelector('#p2-car canvas'), 'canvas «машинка + график» §5.2');
r5.doc.defaultView.goTo('p3'); await wait(100);
assert.ok(r5.doc.querySelector('#p3-livefig canvas'), 'canvas «живой график y=kx/k÷x» §5.3');
assert.ok(r5.doc.querySelectorAll('#p3-live [data-m]').length === 2, 'переключатель прямая/обратная §5.3');
r5.doc.defaultView.goTo('p1'); await wait(100);
assert.ok(r5.doc.querySelector('#p1-game canvas'), 'canvas «координатный тир» §5.1');
assert.deepEqual(r5.errors, [], 'ch5 без ошибок: ' + r5.errors.join(' | '));
// Глава 2 §1 — полоса процента
const r2 = await loadDom('math_6_ch2.html');
r2.doc.defaultView.goTo('p1'); await wait(100);
assert.ok(r2.doc.querySelector('#p1-bar canvas'), 'canvas «полоса процента» §2.1');
r2.doc.defaultView.goTo('p3'); await wait(100);
assert.ok(r2.doc.querySelector('#p3-balfig canvas'), 'canvas «весы пропорции» §2.3');
r2.doc.defaultView.goTo('p4'); await wait(100);
assert.ok(r2.doc.querySelector('#p4-carfig canvas'), 'canvas «постоянная площадь» §2.4');
r2.doc.defaultView.goTo('p7'); await wait(100);
assert.ok(r2.doc.querySelector('#p7-fig canvas'), 'canvas «растущая диаграмма» §2.7');
assert.deepEqual(r2.errors, [], 'ch2 без ошибок: ' + r2.errors.join(' | '));
// Глава 3 §1 — фильтр множества
const r3 = await loadDom('math_6_ch3.html');
r3.doc.defaultView.goTo('p1'); await wait(100);
assert.ok(r3.doc.querySelector('#p1-filterfig canvas'), 'canvas «фильтр множества» §3.1');
assert.deepEqual(r3.errors, [], 'ch3 без ошибок: ' + r3.errors.join(' | '));
});
test('stepPlayer: «Разбор по шагам» становится интерактивным плеером', async () => {
// Глава 5 §1 — есть карточка «Разбор по шагам»
const r5 = await loadDom('math_6_ch5.html');
r5.doc.defaultView.goTo('p1'); await wait(120);
assert.ok(r5.doc.querySelector('#p1-body .m6-step-view'), 'плеер шагов §5.1');
assert.ok(r5.doc.querySelectorAll('#p1-body [data-act="next"]').length >= 1, 'кнопка «Дальше» §5.1');
assert.ok(r5.doc.querySelectorAll('#p1-body .m6-step-dots span').length >= 3, 'точки шагов §5.1');
assert.deepEqual(r5.errors, [], 'ch5 без ошибок: ' + r5.errors.join(' | '));
// Глава 2 §1
const r2 = await loadDom('math_6_ch2.html');
r2.doc.defaultView.goTo('p1'); await wait(120);
assert.ok(r2.doc.querySelector('#p1-body .m6-step-view'), 'плеер шагов §2.1');
assert.deepEqual(r2.errors, [], 'ch2 без ошибок: ' + r2.errors.join(' | '));
});
test('hub: 6 карточек глав + курсовой финал', async () => {
const { doc, errors } = await loadDom('math_6_hub.html');
assert.deepEqual(errors, [], 'нет ошибок: ' + errors.join(' | '));
assert.equal(doc.querySelectorAll('.ch-grid .ch-card').length, 6, '6 глав');
assert.ok(doc.querySelector('#cf-wrap') && doc.querySelector('#cf-go'), 'секция курсового финала');
assert.ok(doc.querySelector('#cf-q').textContent.length > 0, 'первое испытание показано');
});
test('ch1 Волна 1: интерактивы §1–§3 монтируются без ошибок', async () => {
const { doc, errors } = await loadDom('math_6_ch1.html');
const win = doc.defaultView;
// §1 строится при загрузке
assert.ok(doc.querySelector('#p1-iv1 #p1-c'), 'разрядный конструктор §1');
assert.ok(doc.querySelector('#p1-iv2 #p1-qq'), 'разряд-квиз §1');
win.goTo('p2'); await wait(80);
assert.ok(doc.querySelector('#p2-cfig svg'), 'числовая прямая сравнения §2');
assert.ok(doc.querySelector('#p2-iv2 #p2-rq'), 'тренажёр округления §2');
win.goTo('p3'); await wait(80);
assert.ok(doc.querySelector('#p3-afig svg'), 'координатный луч §3');
assert.ok(doc.querySelectorAll('#p3-iv2 [data-pt]').length === 4, 'выбор точки AD §3');
assert.deepEqual(errors, [], 'нет ошибок: ' + errors.join(' | '));
});
test('ch1 Волна 2: интерактивы §4–§6 монтируются без ошибок', async () => {
const { doc, errors } = await loadDom('math_6_ch1.html');
const win = doc.defaultView;
win.goTo('p4'); await wait(80);
assert.ok(doc.querySelector('#p4-fig'), 'столбик §4');
assert.ok(doc.querySelectorAll('#p4-eopts').length === 1, 'варианты §4');
win.goTo('p5'); await wait(80);
assert.ok(doc.querySelector('#p5-iv1 [data-op]'), 'кнопки сдвига запятой §5');
assert.ok(doc.querySelector('#p5-out').textContent.length > 0 || doc.querySelector('#p5-out'), 'демонстратор §5');
win.goTo('p6'); await wait(80);
assert.ok(doc.querySelector('#p6-asl') && doc.querySelector('#p6-bsl'), 'ползунки множителей §6');
assert.ok(doc.querySelector('#p6-q'), 'тренажёр умножения §6');
assert.deepEqual(errors, [], 'нет ошибок: ' + errors.join(' | '));
});
test('ch1 Волна 3: интерактивы §7–§10 монтируются без ошибок', async () => {
const { doc, errors } = await loadDom('math_6_ch1.html');
const win = doc.defaultView;
win.goTo('p7'); await wait(80);
assert.ok(doc.querySelector('#p7-q') && doc.querySelector('#p7-rq'), 'тренажёры деления §7');
win.goTo('p8'); await wait(80);
assert.ok(doc.querySelector('#p8-pick [data-k]'), 'выбор примеров §8');
assert.ok(doc.querySelector('#p8-out').textContent.indexOf('=') >= 0, 'демонстратор переноса запятой §8');
win.goTo('p9'); await wait(80);
assert.ok(doc.querySelector('#p9-iv1 [data-fin]'), 'классификатор §9');
assert.ok(doc.querySelectorAll('#p9-dopts [data-o]').length === 3, 'варианты десятичной §9');
win.goTo('p10'); await wait(80);
assert.ok(doc.querySelectorAll('#p10-pool .dnd-chip').length === 5, 'сопоставление дробей §10');
assert.ok(doc.querySelector('#p10-q'), 'выражения §10');
assert.deepEqual(errors, [], 'нет ошибок: ' + errors.join(' | '));
});
test('ch1 Волна 4: §12 прикладной и финал-боссы', async () => {
const { doc, errors } = await loadDom('math_6_ch1.html');
const win = doc.defaultView;
win.goTo('app'); await wait(80);
assert.ok(doc.querySelector('#app-q') && doc.querySelector('#app-aq'), 'задачи §12');
win.goTo('final'); await wait(80);
assert.ok(doc.querySelector('#fin-q') && doc.querySelector('#fin-go'), 'арена боссов');
// финал на 100% → достижение ch1_done (finalAch)
win.bumpProgress('final', 100); await wait(20);
assert.ok(win.M6STATE.achievements.has('ch1_done'), 'достижение «Глава 1 пройдена» при финале');
assert.deepEqual(errors, [], 'нет ошибок: ' + errors.join(' | '));
});
test('навигация и прогресс: переход на § и отметка прочтения', async () => {
const { doc, errors } = await loadDom('math_6_ch1.html');
const win = doc.defaultView;
win.goTo('p4'); await wait(60);
assert.ok(doc.querySelector('#sec-p4.active'), 'перешли на § 4');
/* отметка прочтения начисляет XP */
const btn = doc.querySelector('#p4-body [data-read]');
assert.ok(btn, 'кнопка прочтения § 4');
btn.click(); await wait(20);
assert.ok((win.M6STATE.progress.p4 || 0) >= 30, 'прогресс § 4 вырос после прочтения');
assert.deepEqual(errors, [], 'нет ошибок: ' + errors.join(' | '));
});