// Автопроверки интерактивного графа связей (dev-only). // // Запускаются ТОЛЬКО в лаборатории при наличии ?fgtest в URL (см. lab.js). Используют детерминированные // dev-хелперы движка (graph.debugState / graph.pumpForTest) — поэтому проходят стабильно даже когда // requestAnimationFrame троттлится в фоновой вкладке (pumpForTest синхронно докручивает кадры до покоя). // // Результат печатается в консоль и кладётся в window.__fgTestResults = { pass, total, results[] }. const DEEP_FAN_HALF_DEG = 110; // допустимое отклонение детей от направления «наружу» (полукруг ~±99° + запас) export async function runNetworkSelfTest(graph, deepChipEl) { const wait = (ms) => new Promise((r) => setTimeout(r, ms)); const results = []; const check = (name, pass, detail) => { results.push({ name, pass: !!pass, detail }); }; const st = () => graph.debugState(); // 1) Включаем режим «Вселенная» и ждём, пока завершится bloom-перестроение (его закрывает setTimeout). if (deepChipEl && !deepChipEl.classList.contains('is-active')) deepChipEl.click(); await wait(1700); let s = st(); const focusId = s.focusId; const tier1 = s.nodes.filter((n) => n.tier === 1 && n.id !== focusId); const parent = tier1.find((n) => n.id === 'nina') || tier1[0]; if (!parent) { check('есть узлы 1-го уровня', false, 'tier-1 не найдены'); return finish(results); } // === Тест A: погружение в узел 1-го уровня (камера-наезд + расталкивание + полукруг) === graph.diveTo({ id: parent.id }); const framesA = graph.pumpForTest(); s = st(); const p = s.nodes.find((n) => n.id === parent.id); const kids = s.nodes.filter((n) => n.tier === 2 && String(n.id).startsWith(parent.id + '__d2_')); check('A1 анимация погружения завершается (freeze)', framesA < 1190, `кадров: ${framesA}`); check('A2 камера зумит (zoom≈DIVE)', s.zoom >= 1.5, `zoom=${s.zoom}`); check('A3 узел центрируется камерой', Math.abs(s.camX + p.x * s.zoom) < 36 && Math.abs(s.camY + p.y * s.zoom) < 36, `offset=(${Math.round(s.camX + p.x * s.zoom)},${Math.round(s.camY + p.y * s.zoom)})`); check('A4 узел вырос (герой)', p.depthScale > 1.2, `depthScale=${p.depthScale}`); // расталкивание: дети не слипаются let minD = Infinity; for (let i = 0; i < kids.length; i += 1) for (let j = i + 1; j < kids.length; j += 1) { minD = Math.min(minD, Math.hypot(kids[i].x - kids[j].x, kids[i].y - kids[j].y)); } check('A5 дети не слипаются (collision)', kids.length >= 2 ? minD > 40 : true, `мин.дистанция=${Math.round(minD)}px`); // полукруг наружу: все дети в секторе вокруг направления от центра к родителю const outward = Math.atan2(p.y, p.x); const maxDev = kids.reduce((mx, k) => { let d = Math.abs(Math.atan2(k.y - p.y, k.x - p.x) - outward); if (d > Math.PI) d = 2 * Math.PI - d; return Math.max(mx, d * 180 / Math.PI); }, 0); check('A6 веер полукругом наружу', kids.length ? maxDev <= DEEP_FAN_HALF_DEG : true, `maxDev=${Math.round(maxDev)}°`); // === Тест B: Spotlight открыт — путь горит, остальное тускнеет === const offPath = tier1.filter((n) => n.id !== parent.id); const offDim = offPath.every((n) => { const x = s.nodes.find((m) => m.id === n.id); return x && x.spotCur < 0.4; }); const pathLit = (s.nodes.find((n) => n.id === parent.id).spotCur > 0.9) && (s.nodes.find((n) => n.id === focusId).spotCur > 0.9); check('B1 путь горит на 100%', pathLit, 'фокус+цель spotCur>0.9'); check('B2 остальные ветки затемнены (~0.25)', offDim, 'все вне пути spotCur<0.4'); // === Тест C: переключение веток сбрасывает прежнюю (нет накопления) === if (offPath.length) { graph.diveTo({ id: offPath[0].id }); graph.pumpForTest(); s = st(); const prev = s.nodes.find((n) => n.id === parent.id); check('C1 прежняя ветка сброшена при переключении', prev.spotCur < 0.4, `прежняя spotCur=${prev.spotCur}`); check('C2 новая цель — активна', s.diveTargetId === offPath[0].id, `dive=${s.diveTargetId}`); } // === Тест D: LOD — дети 3-го уровня становятся аватарками при сильном зуме === const t2withKids = st().nodes.find((n) => n.tier === 2); if (t2withKids) { graph.diveTo({ id: t2withKids.id }); graph.pumpForTest(); s = st(); const t3 = s.nodes.filter((n) => n.tier === 3 && String(n.id).startsWith(t2withKids.id + '_d3_')); const allFull = t3.length ? t3.every((n) => n.lod === 'full') : true; check('D1 точки 3-го уровня → аватарки (LOD)', allFull, `full: ${t3.filter((n) => n.lod === 'full').length}/${t3.length}`); } // === Тест F: поиск по имени находит узел (для строки поиска + телепорта) === const named = st().nodes.find((n) => n.tier === 1 && n.id !== st().focusId); if (named && typeof graph.findNode === 'function') { const byId = graph.findNode(named.id); check('F1 поиск находит узел по логину', byId && byId.id === named.id, `найдено: ${byId && byId.id}`); } // === Тест G: хлебные крошки — путь focus → … → цель (мы сейчас в t2withKids) === if (typeof graph.getDivePath === 'function' && t2withKids) { const path = graph.getDivePath(); const okPath = path.length >= 2 && path[0].isFocus && path[path.length - 1].id === t2withKids.id; check('G1 хлебные крошки строят путь к цели', okPath, `путь: ${path.map((p) => p.name).join(' › ')}`); } // === Тест H: бейдж числа связей виден и числовой (DOM) === if (typeof document !== 'undefined') { const fb = document.querySelector('.fg-node.is-focus .fg-node-badge'); const fbOk = fb && !fb.hidden && Number(fb.textContent) > 0; check('H1 бейдж числа связей на центре', !!fbOk, `центр: ${fb ? fb.textContent : 'нет'}`); } // === Тест I: общие связи — есть узлы с золотым ободком ★ (общий друг) === if (typeof document !== 'undefined') { const commonCount = document.querySelectorAll('.fg-node.is-common').length; check('I1 общие связи помечены (★)', commonCount >= 1, `узлов «общая связь»: ${commonCount}`); } // === Тест J: доступность — текстовый список графа для скринридеров === if (typeof document !== 'undefined') { const a11y = document.querySelector('.fg-a11y'); const liCount = a11y ? a11y.querySelectorAll('li').length : 0; check('J1 sr-only список графа заполнен', !!a11y && liCount >= 1, `пунктов списка: ${liCount}`); } // === Тест E: выход — ВСЕ узлы возвращают 100% яркости, зум отъезжает === graph.exitDive(); graph.pumpForTest(); s = st(); const allBright = s.nodes.filter((n) => n.tier === 1).every((n) => n.spotCur > 0.95); check('E1 выход: все узлы 100% яркости', allBright, 'tier-1 spotCur>0.95'); check('E2 выход: зум вернулся (~1)', Math.abs(s.zoom - 1) < 0.05, `zoom=${s.zoom}`); check('E3 выход: погружение снято', s.diveTargetId === null, `dive=${s.diveTargetId}`); return finish(results); } function finish(results) { const pass = results.filter((r) => r.pass).length; const out = { pass, total: results.length, results }; if (typeof window !== 'undefined') window.__fgTestResults = out; const tag = pass === results.length ? '✅ PASS' : '❌ FAIL'; // eslint-disable-next-line no-console console.log(`[fg-selftest] ${tag} ${pass}/${results.length}`); results.forEach((r) => console.log(` ${r.pass ? '✓' : '✗'} ${r.name} — ${r.detail}`)); return out; }