Вариант 2 (всё в лаборатории, реальный путь /network-view не трогаем). - Общие связи: среди друзей человека один помечен как «общий» (он и твой друг тоже) — золотой ободок + ★ (CSS .fg-node.is-common). В лаб-генерации addDeepLevels подставляет узнаваемого друга Ивана. - Доступность: визуально скрытый (sr-only) текстовый список графа .fg-a11y (центр + связи 1-го уровня) для скринридеров; обновляется в updateA11y при перестроении (role=region, aria-label). Автопроверки расширены до 19 ассертов (добавлены «общие связи ★» и sr-only список) — прогон 19/19 PASS. Бамп client.version → 1.2.150. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
140 lines
8.5 KiB
JavaScript
140 lines
8.5 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}`);
|
||
}
|
||
|
||
// === Тест 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;
|
||
}
|