Связи: финальный вид сияющих связей

Перенести финальный плазменный рендер связей из коммита Pixel 2559f1e6 в main.
This commit is contained in:
AidarKC
2026-06-10 19:00:22 +04:00
parent 9ca469a075
commit e3061b46f9
5 changed files with 211 additions and 37 deletions
+150
View File
@@ -0,0 +1,150 @@
// Автопроверки интерактивного графа связей (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;
}