SHiNE-server/shine-UI/js/pages/network/selftest.js
AidarKC e3061b46f9 Связи: финальный вид сияющих связей
Перенести финальный плазменный рендер связей из коммита Pixel 2559f1e6 в main.
2026-06-10 19:00:22 +04:00

151 lines
9.3 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.

// Автопроверки интерактивного графа связей (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}`);
// === Тест K: сияющие линии — плазма из 3 слоёв на ОДНОМ S-пути (одинаковый d) ===
if (typeof document !== 'undefined') {
const flare = document.querySelectorAll('.fg-plasma-flare');
const tube = document.querySelectorAll('.fg-plasma-tube');
const core = document.querySelectorAll('.fg-plasma-core');
const equalLayers = flare.length >= 1 && flare.length === tube.length && tube.length === core.length;
const sameD = flare[0] && flare[0].getAttribute('d') === tube[0].getAttribute('d')
&& tube[0].getAttribute('d') === core[0].getAttribute('d');
check('K1 плазма: 3 слоя на ОДНОМ S-пути', equalLayers && !!sameD, `поле:${flare.length} трубка:${tube.length} ядро:${core.length} sameD:${!!sameD}`);
}
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;
}