refactor(decnet_web/MazeNET): wire hooks + bump coverage floor
Final integration step. The MazeNET page shell is now a thinner composition of the existing module-level hooks (useMazeApi, useMazeInteraction, useTopologyEditor, useTopologyStream, useLayoutPersistor) PLUS the three new ones from this phase (useFullscreenMode, useTopologyData, useMazeContextMenu). - MazeNET.tsx: 980 -> 715 LOC. The fullscreen + body-class effects, the topology hydrate / SSE stream / deploy / flashErr plumbing, and the four context-menu builders are all gone from the shell. - Page still owns the per-operation editor callbacks (removeNet/Node/Edge, duplicateNode, addServiceToNode, etc.) because they need direct access to setNodes/setEdges/setNets for optimistic patches alongside their REST calls — those setters are exposed by useTopologyData for that reason. Coverage floor bumped after the phase: lines 17 -> 19 functions 15 -> 17 branches 13 -> 14 statements 16 -> 18 Phase 5 final scoreboard: 37 test files, 172 tests, all green.
This commit is contained in:
@@ -3,27 +3,26 @@ import { useSearchParams, useNavigate } from 'react-router-dom';
|
|||||||
import {
|
import {
|
||||||
PanelRightOpen, PanelRightClose, PanelLeftOpen, PanelLeftClose,
|
PanelRightOpen, PanelRightClose, PanelLeftOpen, PanelLeftClose,
|
||||||
Maximize2, Minimize2, RotateCcw, UploadCloud, ArrowLeft,
|
Maximize2, Minimize2, RotateCcw, UploadCloud, ArrowLeft,
|
||||||
Plus, Trash2, Zap, Copy, Eye, ShieldAlert, GitMerge, Server, Mail,
|
Server, Mail,
|
||||||
} from '../../icons';
|
} from '../../icons';
|
||||||
import './MazeNET.css';
|
import './MazeNET.css';
|
||||||
import axios, { type ApiError } from '../../utils/api';
|
import axios from '../../utils/api';
|
||||||
import { useSwarmHosts } from '../../hooks/useSwarmHosts';
|
import { useSwarmHosts } from '../../hooks/useSwarmHosts';
|
||||||
import Palette from './Palette';
|
import Palette from './Palette';
|
||||||
import Canvas from './Canvas';
|
import Canvas from './Canvas';
|
||||||
import Inspector from './Inspector';
|
import Inspector from './Inspector';
|
||||||
import type { Selection } from './Inspector';
|
import type { Selection } from './Inspector';
|
||||||
import ContextMenu, { type MenuItem } from './ContextMenu';
|
import ContextMenu from './ContextMenu';
|
||||||
import { DEFAULT_SERVICES } from './data';
|
import type { Net, MazeNode, DeckyNode } from './types';
|
||||||
import type { Archetype, ServiceDef } from './data';
|
import type { Archetype } from './data';
|
||||||
import type { Net, MazeNode, Edge, DeckyNode } from './types';
|
|
||||||
import { useMazeApi } from './useMazeApi';
|
import { useMazeApi } from './useMazeApi';
|
||||||
import type { DeckyRow } from './useMazeApi';
|
import type { DeckyRow } from './useMazeApi';
|
||||||
import { useTopologyEditor } from './useTopologyEditor';
|
import { useTopologyEditor } from './useTopologyEditor';
|
||||||
import { useMazeInteraction, type PaletteDrag } from './useMazeInteraction';
|
import { useMazeInteraction, type PaletteDrag } from './useMazeInteraction';
|
||||||
import { useLayoutPersistor } from './useMazeLayoutStore';
|
import { useLayoutPersistor } from './useMazeLayoutStore';
|
||||||
import { useTopologyStream, type TopologyStreamEvent } from './useTopologyStream';
|
|
||||||
import { useFullscreenMode } from './useFullscreenMode';
|
import { useFullscreenMode } from './useFullscreenMode';
|
||||||
import { ARCHETYPES as DEFAULT_ARCHETYPES } from './data';
|
import { useTopologyData } from './useTopologyData';
|
||||||
|
import { useMazeContextMenu } from './useMazeContextMenu';
|
||||||
import { useToast } from '../Toasts/useToast';
|
import { useToast } from '../Toasts/useToast';
|
||||||
import { useServiceRegistry } from '../../hooks/useServiceRegistry';
|
import { useServiceRegistry } from '../../hooks/useServiceRegistry';
|
||||||
import AddServiceConfigModal from '../AddServiceConfigModal';
|
import AddServiceConfigModal from '../AddServiceConfigModal';
|
||||||
@@ -177,36 +176,28 @@ const MazeNET: React.FC = () => {
|
|||||||
const topologyId = params.get('topology') ?? '';
|
const topologyId = params.get('topology') ?? '';
|
||||||
|
|
||||||
const { byUuid: hostsByUuid } = useSwarmHosts();
|
const { byUuid: hostsByUuid } = useSwarmHosts();
|
||||||
const [nets, setNets] = useState<Net[]>([]);
|
const data = useTopologyData(api, topologyId);
|
||||||
const [nodes, setNodes] = useState<MazeNode[]>([]);
|
const {
|
||||||
const [edges, setEdges] = useState<Edge[]>([]);
|
nets, setNets, nodes, setNodes, edges, setEdges,
|
||||||
const [topoStatus, setTopoStatus] = useState<string>('pending');
|
topoMeta, services, archetypes,
|
||||||
const [topoName, setTopoName] = useState<string>('');
|
loadErr, actionErr, flashErr,
|
||||||
const [topoVersion, setTopoVersion] = useState<number>(0);
|
deploying, onDeploy,
|
||||||
const [topoTargetHost, setTopoTargetHost] = useState<string | null>(null);
|
streamLive, lastEventAt, streamEnabled,
|
||||||
const [topoMode, setTopoMode] = useState<string>('unihost');
|
refetch,
|
||||||
|
} = data;
|
||||||
|
const { status: topoStatus, name: topoName, version: topoVersion,
|
||||||
|
targetHost: topoTargetHost, mode: topoMode } = topoMeta;
|
||||||
const [selection, setSelection] = useState<Selection>(null);
|
const [selection, setSelection] = useState<Selection>(null);
|
||||||
const [inspectorOpen, setInspectorOpen] = useState(true);
|
const [inspectorOpen, setInspectorOpen] = useState(true);
|
||||||
const [paletteOpen, setPaletteOpen] = useState(true);
|
const [paletteOpen, setPaletteOpen] = useState(true);
|
||||||
const { fullscreen, toggle: toggleFullscreen } = useFullscreenMode();
|
const { fullscreen, toggle: toggleFullscreen } = useFullscreenMode();
|
||||||
const [services, setServices] = useState<ServiceDef[]>(DEFAULT_SERVICES);
|
|
||||||
const [archetypes, setArchetypes] = useState<Archetype[]>(DEFAULT_ARCHETYPES);
|
|
||||||
|
|
||||||
useLayoutPersistor(topologyId || null, nets, nodes);
|
useLayoutPersistor(topologyId || null, nets, nodes);
|
||||||
const [loadErr, setLoadErr] = useState<string | null>(null);
|
|
||||||
const [actionErr, setActionErr] = useState<string | null>(null);
|
|
||||||
const [deploying, setDeploying] = useState(false);
|
|
||||||
|
|
||||||
const canvasRef = useRef<HTMLDivElement>(null);
|
const canvasRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const editor = useTopologyEditor({ api, topoStatus, topoVersion });
|
const editor = useTopologyEditor({ api, topoStatus, topoVersion });
|
||||||
|
|
||||||
const flashErr = useCallback((err: unknown, fallback: string) => {
|
|
||||||
const msg = (err as ApiError)?.response?.data?.detail ?? (err as ApiError)?.message ?? fallback;
|
|
||||||
setActionErr(msg);
|
|
||||||
setTimeout(() => setActionErr(null), 4000);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
/* ── Live service mutation (W3 endpoints) — hoisted above palette
|
/* ── Live service mutation (W3 endpoints) — hoisted above palette
|
||||||
drop so onPaletteDrop's deps can reference it without hitting the
|
drop so onPaletteDrop's deps can reference it without hitting the
|
||||||
const TDZ. Optimistic local update; SSE forwarder reconciles
|
const TDZ. Optimistic local update; SSE forwarder reconciles
|
||||||
@@ -381,8 +372,6 @@ const MazeNET: React.FC = () => {
|
|||||||
onPaletteDrop, onReparent, onAddEdge,
|
onPaletteDrop, onReparent, onAddEdge,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; items: MenuItem[] } | null>(null);
|
|
||||||
|
|
||||||
const removeNet = async (id: string) => {
|
const removeNet = async (id: string) => {
|
||||||
const net = nets.find((n) => n.id === id);
|
const net = nets.find((n) => n.id === id);
|
||||||
if (!net || net.kind === 'internet') return;
|
if (!net || net.kind === 'internet') return;
|
||||||
@@ -518,238 +507,19 @@ const MazeNET: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/* Force-mutate is a no-op against a pending topology (no live containers).
|
// Load + SSE + deploy + flashErr live in useTopologyData (above).
|
||||||
* Keep the menu item disabled for now; real hook lands with live-editing polish. */
|
|
||||||
const forceMutate = (_id: string) => {
|
|
||||||
flashErr(null, 'force-mutate only applies to deployed topologies');
|
|
||||||
};
|
|
||||||
|
|
||||||
const onNodeContextMenu = (id: string) => (e: React.MouseEvent) => {
|
const ctx = useMazeContextMenu({
|
||||||
e.preventDefault();
|
nets, nodes, services, archetypes, topologyId,
|
||||||
e.stopPropagation();
|
setSelection, setNodes,
|
||||||
const node = nodes.find((n) => n.id === id);
|
canvasRef, pan: interaction.pan,
|
||||||
if (!node) return;
|
editor, flashErr, onPaletteDrop,
|
||||||
setSelection({ type: 'node', id });
|
removeNet, removeNode, removeEdge, duplicateNode, addServiceToNode,
|
||||||
const isObs = node.kind === 'observed';
|
|
||||||
const isGateway = node.kind === 'decky' && !!node.decky_config?.forwards_l3;
|
|
||||||
const locked = isObs || isGateway;
|
|
||||||
const lockedTitle = isObs
|
|
||||||
? 'observed entity — not a deployed decky'
|
|
||||||
: isGateway ? 'DMZ gateway — pinned to its DMZ network' : undefined;
|
|
||||||
const usedServices = node.kind === 'decky' ? new Set(node.services) : new Set<string>();
|
|
||||||
const serviceSubmenu: MenuItem[] = services
|
|
||||||
.filter((s) => !usedServices.has(s.slug))
|
|
||||||
.slice(0, 16)
|
|
||||||
.map((s) => ({
|
|
||||||
label: `${s.name} · ${s.proto.toUpperCase()}:${s.port}`,
|
|
||||||
disabled: isObs,
|
|
||||||
onClick: () => addServiceToNode(id, s.slug),
|
|
||||||
}));
|
|
||||||
if (serviceSubmenu.length === 0) {
|
|
||||||
serviceSubmenu.push({ label: '(no free services)', disabled: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
setCtxMenu({
|
|
||||||
x: e.clientX, y: e.clientY,
|
|
||||||
items: [
|
|
||||||
{ label: 'Add service…', icon: <Plus size={12} />, disabled: isObs,
|
|
||||||
title: isObs ? 'observed entity — services fixed' : undefined,
|
|
||||||
submenu: serviceSubmenu },
|
|
||||||
{ label: 'Force mutate', icon: <Zap size={12} />, disabled: isObs,
|
|
||||||
onClick: () => forceMutate(id) },
|
|
||||||
{ label: 'Duplicate decky', icon: <Copy size={12} />, disabled: locked,
|
|
||||||
title: lockedTitle, onClick: () => duplicateNode(id) },
|
|
||||||
{ separator: true, label: '' },
|
|
||||||
{ label: 'Delete decky', icon: <Trash2 size={12} />, danger: true,
|
|
||||||
disabled: locked, title: lockedTitle,
|
|
||||||
onClick: () => removeNode(id) },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const onNetContextMenu = (id: string) => (e: React.MouseEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
const net = nets.find((n) => n.id === id);
|
|
||||||
if (!net) return;
|
|
||||||
setSelection({ type: 'net', id });
|
|
||||||
const archetypeSubmenu: MenuItem[] = archetypes.map((a) => ({
|
|
||||||
label: a.name, icon: <Server size={12} />,
|
|
||||||
onClick: async () => {
|
|
||||||
const name = `decky-${tempIdSuffix()}`;
|
|
||||||
try {
|
|
||||||
const dRes = await editor.addDeckyToLan(
|
|
||||||
topologyId,
|
|
||||||
{ name, services: [...a.services], x: 20, y: 40,
|
|
||||||
decky_config: { archetype: a.slug } },
|
|
||||||
id, net.name,
|
|
||||||
);
|
|
||||||
if (dRes.kind !== 'applied') return;
|
|
||||||
const decky = dRes.data;
|
|
||||||
const node: DeckyNode = {
|
|
||||||
kind: 'decky', id: decky.uuid, netId: id, name: decky.name,
|
|
||||||
archetype: a.slug, services: [...a.services], status: 'idle',
|
|
||||||
x: 20, y: 40,
|
|
||||||
};
|
|
||||||
setNodes((p) => [...p, node]);
|
|
||||||
} catch (err) {
|
|
||||||
flashErr(err, 'create decky failed');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
setCtxMenu({
|
|
||||||
x: e.clientX, y: e.clientY,
|
|
||||||
items: [
|
|
||||||
{ label: 'Add decky…', icon: <Plus size={12} />, submenu: archetypeSubmenu },
|
|
||||||
{ label: 'Inspect', icon: <Eye size={12} />, onClick: () => setSelection({ type: 'net', id }) },
|
|
||||||
{ separator: true, label: '' },
|
|
||||||
{ label: net.kind === 'dmz' ? 'Delete DMZ' : 'Delete network',
|
|
||||||
icon: <Trash2 size={12} />, danger: true,
|
|
||||||
disabled: net.kind === 'internet',
|
|
||||||
title: net.kind === 'internet' ? 'internet zone cannot be removed' : undefined,
|
|
||||||
onClick: () => removeNet(id) },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const onEdgeContextMenu = (id: string) => (e: React.MouseEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setSelection({ type: 'edge', id });
|
|
||||||
setCtxMenu({
|
|
||||||
x: e.clientX, y: e.clientY,
|
|
||||||
items: [
|
|
||||||
{ label: 'Remove edge', icon: <Trash2 size={12} />, danger: true, onClick: () => removeEdge(id) },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const onCanvasContextMenu = (e: React.MouseEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setCtxMenu({
|
|
||||||
x: e.clientX, y: e.clientY,
|
|
||||||
items: [
|
|
||||||
{ label: 'Add subnet here', icon: <GitMerge size={12} />,
|
|
||||||
onClick: () => {
|
|
||||||
const rect = canvasRef.current?.getBoundingClientRect();
|
|
||||||
const wx = e.clientX - (rect?.left ?? 0) - interaction.pan.x;
|
|
||||||
const wy = e.clientY - (rect?.top ?? 0) - interaction.pan.y;
|
|
||||||
onPaletteDrop(
|
|
||||||
{ kind: 'network-subnet', slug: 'subnet', label: 'SUBNET', clientX: e.clientX, clientY: e.clientY },
|
|
||||||
{ x: wx, y: wy }, null, null,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ label: 'Add DMZ here', icon: <ShieldAlert size={12} />,
|
|
||||||
onClick: () => {
|
|
||||||
const rect = canvasRef.current?.getBoundingClientRect();
|
|
||||||
const wx = e.clientX - (rect?.left ?? 0) - interaction.pan.x;
|
|
||||||
const wy = e.clientY - (rect?.top ?? 0) - interaction.pan.y;
|
|
||||||
onPaletteDrop(
|
|
||||||
{ kind: 'network-dmz', slug: 'dmz', label: 'DMZ', clientX: e.clientX, clientY: e.clientY },
|
|
||||||
{ x: wx, y: wy }, null, null,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/* Load catalogs. */
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
api.getServices().then((s) => { if (!cancelled) setServices(s); }).catch(() => {});
|
|
||||||
api.getArchetypes().then((a) => { if (!cancelled) setArchetypes(a); }).catch(() => {});
|
|
||||||
return () => { cancelled = true; };
|
|
||||||
}, [api]);
|
|
||||||
|
|
||||||
/* Hydrate topology. Route guard in App.tsx ensures topologyId is set;
|
|
||||||
* if the id is bogus, surface a friendly error. */
|
|
||||||
const refetch = useCallback(async () => {
|
|
||||||
if (!topologyId) return;
|
|
||||||
try {
|
|
||||||
const h = await api.getTopology(topologyId);
|
|
||||||
setNets(h.nets); setNodes(h.nodes); setEdges(h.edges);
|
|
||||||
setTopoStatus(h.topology.status);
|
|
||||||
setTopoName(h.topology.name);
|
|
||||||
setTopoVersion(h.topology.version);
|
|
||||||
setTopoMode(h.topology.mode ?? 'unihost');
|
|
||||||
setTopoTargetHost(h.topology.target_host_uuid ?? null);
|
|
||||||
setLoadErr(null);
|
|
||||||
} catch (err) {
|
|
||||||
setLoadErr((err as Error)?.message ?? 'topology load failed');
|
|
||||||
}
|
|
||||||
}, [api, topologyId]);
|
|
||||||
|
|
||||||
useEffect(() => { refetch(); }, [refetch]);
|
|
||||||
|
|
||||||
/* Live topology stream. Open only when the topology is deployed —
|
|
||||||
* pending topologies have no mutator loop and would just idle on
|
|
||||||
* keepalives. On any state-transition event we refetch; DB is the
|
|
||||||
* source of truth and the bus is at-most-once. */
|
|
||||||
const [streamLive, setStreamLive] = useState(false);
|
|
||||||
const [lastEventAt, setLastEventAt] = useState<Date | null>(null);
|
|
||||||
const streamEnabled = topoStatus === 'active' || topoStatus === 'degraded';
|
|
||||||
const onStreamEvent = useCallback((event: TopologyStreamEvent) => {
|
|
||||||
// Flip LIVE only on named, purposeful events — not incidental keepalives.
|
|
||||||
if (event.name === 'snapshot'
|
|
||||||
|| event.name.startsWith('mutation.')
|
|
||||||
|| event.name === 'status') {
|
|
||||||
setStreamLive(true);
|
|
||||||
setLastEventAt(new Date());
|
|
||||||
}
|
|
||||||
if (event.name === 'mutation.failed') {
|
|
||||||
const p = event.payload ?? {};
|
|
||||||
const reason = typeof p.reason === 'string' ? p.reason
|
|
||||||
: typeof p.error === 'string' ? p.error
|
|
||||||
: 'mutation failed — check mutator logs';
|
|
||||||
setActionErr(`mutation failed: ${reason}`);
|
|
||||||
setTimeout(() => setActionErr(null), 6000);
|
|
||||||
}
|
|
||||||
if (event.name === 'mutation.applied'
|
|
||||||
|| event.name === 'mutation.failed'
|
|
||||||
|| event.name === 'status') {
|
|
||||||
refetch();
|
|
||||||
}
|
|
||||||
// Live service mutations from another tab / admin: optimistically
|
|
||||||
// patch local state so the chip set reflects shape without a full
|
|
||||||
// re-hydrate. The post-mutation services list lives on the
|
|
||||||
// payload; same shape the actor's POST/DELETE response carries.
|
|
||||||
if (event.name === 'decky.service_added'
|
|
||||||
|| event.name === 'decky.service_removed') {
|
|
||||||
const p = event.payload ?? {};
|
|
||||||
const deckyName = typeof p.decky_name === 'string' ? p.decky_name : null;
|
|
||||||
const services = Array.isArray(p.services) ? p.services as string[] : null;
|
|
||||||
if (deckyName && services) {
|
|
||||||
setNodes((prev) => prev.map((n) => n.kind === 'decky' && n.name === deckyName
|
|
||||||
? { ...n, services } : n));
|
|
||||||
setStreamLive(true);
|
|
||||||
setLastEventAt(new Date());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [refetch]);
|
|
||||||
const onStreamError = useCallback(() => { setStreamLive(false); }, []);
|
|
||||||
useTopologyStream({
|
|
||||||
topologyId: streamEnabled ? topologyId : null,
|
|
||||||
enabled: streamEnabled,
|
|
||||||
onEvent: onStreamEvent,
|
|
||||||
onError: onStreamError,
|
|
||||||
});
|
});
|
||||||
useEffect(() => { if (!streamEnabled) setStreamLive(false); }, [streamEnabled]);
|
const onNodeContextMenu = ctx.onNodeContextMenu;
|
||||||
|
const onNetContextMenu = ctx.onNetContextMenu;
|
||||||
const onDeploy = async () => {
|
const onEdgeContextMenu = ctx.onEdgeContextMenu;
|
||||||
if (!topologyId) return;
|
const onCanvasContextMenu = ctx.onCanvasContextMenu;
|
||||||
setDeploying(true);
|
|
||||||
try {
|
|
||||||
await api.deployTopology(topologyId);
|
|
||||||
await refetch();
|
|
||||||
} catch (err) {
|
|
||||||
flashErr(err, 'deploy failed');
|
|
||||||
} finally {
|
|
||||||
setDeploying(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
@@ -878,8 +648,8 @@ const MazeNET: React.FC = () => {
|
|||||||
panLayerRef={interaction.panLayerRef}
|
panLayerRef={interaction.panLayerRef}
|
||||||
gridPatternRef={interaction.gridPatternRef}
|
gridPatternRef={interaction.gridPatternRef}
|
||||||
/>
|
/>
|
||||||
{ctxMenu && (
|
{ctx.ctxMenu && (
|
||||||
<ContextMenu x={ctxMenu.x} y={ctxMenu.y} items={ctxMenu.items} onClose={() => setCtxMenu(null)} />
|
<ContextMenu x={ctx.ctxMenu.x} y={ctx.ctxMenu.y} items={ctx.ctxMenu.items} onClose={ctx.closeMenu} />
|
||||||
)}
|
)}
|
||||||
{interaction.paletteDrag && (
|
{interaction.paletteDrag && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -15,14 +15,16 @@ export default defineConfig({
|
|||||||
include: ['src/**/*.{ts,tsx}'],
|
include: ['src/**/*.{ts,tsx}'],
|
||||||
exclude: ['src/**/*.d.ts', 'src/test/**', 'src/main.tsx'],
|
exclude: ['src/**/*.d.ts', 'src/test/**', 'src/main.tsx'],
|
||||||
// Baseline floors. Each refactor PR raises these; never lower.
|
// Baseline floors. Each refactor PR raises these; never lower.
|
||||||
// Phase 4 (Config split): page shell down from 989 to 131 LOC;
|
// Phase 5 (MazeNET trim): page shell down from 980 to 715 LOC
|
||||||
// hook + WorkersPanel + 4 tab files, 25 new tests. Suite:
|
// (already partially modular; lifted fullscreen, topology data
|
||||||
// 34 files, 156 tests, 17.73% lines / 13.85% branches.
|
// plane, and context-menu builder into focused hooks).
|
||||||
|
// 16 new tests. Suite: 37 files, 172 tests,
|
||||||
|
// 19.49% lines / 14.67% branches.
|
||||||
thresholds: {
|
thresholds: {
|
||||||
lines: 17,
|
lines: 19,
|
||||||
functions: 15,
|
functions: 17,
|
||||||
branches: 13,
|
branches: 14,
|
||||||
statements: 16,
|
statements: 18,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user