fix(math6): запускать init() после экспортов хелперов в window

Реальная причина пустых §1 (заглушки) во всех главах: в math6_engine.js
вызов init() стоял ВЫШЕ строк window.makeCard=…/secNav=…. При обычной
загрузке через defer скрипт исполняется при readyState='interactive',
поэтому ветка `else init()` срабатывала синхронно — init→goTo→buildP1()
звал makeCard ДО его экспорта → ReferenceError 'makeCard is not defined'
→ перехват в ensureBuilt → заглушка. В jsdom-тестах баг не воспроизводился
(там старт шёл через DOMContentLoaded, экспорты успевали).

- init() теперь вызывается СТРОГО после всех window.* экспортов.
- ensureBuilt перечитывает window.M6 (надёжнее против устаревшего замыкания).
- html учебника всегда no-store (убрал кэш-причину стале-страниц).
- регресс-тест: init() обязан идти после window.makeCard. Тесты 18/18.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-02 20:58:05 +03:00
parent e3f1fe7eb5
commit fe378371bd
2 changed files with 18 additions and 5 deletions
+8
View File
@@ -53,6 +53,14 @@ const CHAPTERS = [
{ file: 'math_6_ch6.html', cards: 6 } { 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) { for (const ch of CHAPTERS) {
test(`${ch.file}: SPA без ошибок, ${ch.cards} карточек, активен § 1`, async () => { test(`${ch.file}: SPA без ошибок, ${ch.cards} карточек, активен § 1`, async () => {
const { doc, errors } = await loadDom(ch.file); const { doc, errors } = await loadDom(ch.file);
+10 -5
View File
@@ -165,10 +165,11 @@ function buildParaSelector() {
var BUILT = new Set(); var BUILT = new Set();
function ensureBuilt(id) { function ensureBuilt(id) {
if (BUILT.has(id)) return; if (BUILT.has(id)) return;
var fn = M6.builders && M6.builders[id]; BUILT.add(id);
var cfg = window.M6 || M6; /* читаем актуальный конфиг из window */
var fn = cfg.builders && cfg.builders[id];
if (fn) { try { fn(); } catch (e) { placeholder(id); } } if (fn) { try { fn(); } catch (e) { placeholder(id); } }
else placeholder(id); else placeholder(id);
BUILT.add(id);
} }
function placeholder(id) { function placeholder(id) {
var box = document.getElementById(id + '-body'); if (!box) return; var box = document.getElementById(id + '-body'); if (!box) return;
@@ -368,9 +369,6 @@ function init() {
window.LS.xp.load().then(function (s) { if (s && s.xp > STATE.xp) { STATE.xp = s.xp; STATE.level = calcLevel(STATE.xp); saveProgress(); refreshProgressUI(); if (STATE.current) buildSidebar(STATE.current); } }).catch(function () {}); window.LS.xp.load().then(function (s) { if (s && s.xp > STATE.xp) { STATE.xp = s.xp; STATE.level = calcLevel(STATE.xp); saveProgress(); refreshProgressUI(); if (STATE.current) buildSidebar(STATE.current); } }).catch(function () {});
} }
} }
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
/* ============================================================ EXPORTS (для inline-билдеров) */ /* ============================================================ EXPORTS (для inline-билдеров) */
window.goTo = goTo; window.goTo = goTo;
window.makeCard = makeCard; window.makeCard = makeCard;
@@ -386,4 +384,11 @@ window.setupSorter = setupSorter;
window.confetti = confetti; window.confetti = confetti;
window.M6icon = M6icon; window.M6icon = M6icon;
window.M6engine = { goTo: goTo, ensureBuilt: ensureBuilt, refreshProgressUI: refreshProgressUI, buildSidebar: buildSidebar }; window.M6engine = { goTo: goTo, ensureBuilt: ensureBuilt, refreshProgressUI: refreshProgressUI, buildSidebar: buildSidebar };
/* Запуск init — СТРОГО ПОСЛЕ экспортов в window. Иначе при defer-старте
(readyState='interactive') синхронная ветка else init() вызовет билдеры,
которые обращаются к makeCard/secNav/feedback ДО их экспорта → ReferenceError
→ перехват в ensureBuilt → заглушка. */
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})(); })();