Усиления (движок-полиш) с детерминированной самопроверкой: - Веер детей — полукругом «наружу» (DEEP_FAN, по sibIndex от направления деда→родитель): не перекрывает нить-крошку и родителя; равномерное распределение. - LOD с гистерезисом (LOD_ZOOM_UP=1.6 / DOWN=1.4) — точки 3-го уровня ↔ аватарки без «мигания» у порога. - Двойной тап по пустому фону и сильный pinch-out на минимальном зуме = быстрый выход из погружения. - Префетч аватарок детей при наведении/нырке (prefetchChildren) — лица в кэше до раскрытия. Автопроверки (dev-only, ТОЛЬКО при ?fgtest): - js/pages/network/selftest.js — 14 ассертов: камера-центровка, collision (нет слипания), полукруг, spotlight (путь 1.0 / фон 0.25 / сброс при переключении / 100% на выходе), LOD, возврат зума. - Движок: read-only graph.debugState() + graph.pumpForTest() (синхронно докручивает кадры до покоя, не зависит от троттлинга rAF в фоне). Граф как window.__fg — тоже только при ?fgtest. - Прогон: 14/14 PASS (offset 0px, мин.дистанция детей 89px, веер ±99°, LOD 4/4). В обычной работе тест-хелперы не активны. Реальный путь /network-view не затронут. Бамп client → 1.2.148. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
106 lines
6.4 KiB
JavaScript
106 lines
6.4 KiB
JavaScript
// Автопроверки интерактивного графа связей (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}`);
|
||
}
|
||
|
||
// === Тест 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;
|
||
}
|