mirror of
https://github.com/Quad4-Software/MeshChatX.git
synced 2026-08-18 09:49:09 -04:00
593 lines
22 KiB
JavaScript
593 lines
22 KiB
JavaScript
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
import {
|
|
graphToSceneRequest,
|
|
isVisualiserWebGLSceneReady,
|
|
KIND_ME,
|
|
KIND_IFACE_ON,
|
|
KIND_IFACE_OFF,
|
|
KIND_PEER,
|
|
KIND_DISCOVERED,
|
|
pointerDistance,
|
|
pointerMidpoint,
|
|
canUseVisualiserWebGL,
|
|
createVisualiserWebGLEngine,
|
|
collectWebGLLabels,
|
|
truncateWebGLLabel,
|
|
WEBGL_LABEL_MAX_CHARS,
|
|
} from "@/js/networkVisualiserWebGLEngine.js";
|
|
import {
|
|
atlasUvForSlot,
|
|
mergeSceneNodesWithTextures,
|
|
resolveVisualiserAssetUrl,
|
|
SCENE_NODE_STRIDE,
|
|
NODE_STRIDE,
|
|
ATLAS_COLS,
|
|
ATLAS_ROWS,
|
|
tryCreateWebGL2Context,
|
|
} from "@/js/networkVisualiserWebGL.js";
|
|
|
|
const SCENE_READY_FNS = [
|
|
"meshchatxVisualiserSceneSet",
|
|
"meshchatxVisualiserSceneGetDrawBuffers",
|
|
"meshchatxVisualiserSceneTick",
|
|
"meshchatxVisualiserScenePick",
|
|
"meshchatxVisualiserBuildPathGraph",
|
|
"meshchatxVisualiserBuildFullGraph",
|
|
"meshchatxVisualiserLayout",
|
|
"meshchatxVisualiserPathHashes",
|
|
"meshchatxVisualiserDedupeIcons",
|
|
];
|
|
|
|
function clearSceneGlobals() {
|
|
for (const name of SCENE_READY_FNS) {
|
|
delete globalThis[name];
|
|
}
|
|
delete globalThis.meshchatxVisualiserScenePanBy;
|
|
delete globalThis.meshchatxVisualiserSceneZoomAt;
|
|
delete globalThis.meshchatxVisualiserSceneDragStart;
|
|
delete globalThis.meshchatxVisualiserSceneDragTo;
|
|
delete globalThis.meshchatxVisualiserSceneDragEnd;
|
|
delete globalThis.meshchatxVisualiserSceneResize;
|
|
delete globalThis.meshchatxVisualiserSceneGetPositions;
|
|
}
|
|
|
|
function installSceneReadyStubs(overrides = {}) {
|
|
for (const name of SCENE_READY_FNS) {
|
|
globalThis[name] = overrides[name] || (() => null);
|
|
}
|
|
}
|
|
|
|
function stubGl() {
|
|
return {
|
|
createShader: () => ({}),
|
|
shaderSource: vi.fn(),
|
|
compileShader: vi.fn(),
|
|
getShaderParameter: () => true,
|
|
getShaderInfoLog: () => "",
|
|
deleteShader: vi.fn(),
|
|
createProgram: () => ({}),
|
|
attachShader: vi.fn(),
|
|
linkProgram: vi.fn(),
|
|
getProgramParameter: () => true,
|
|
getProgramInfoLog: () => "",
|
|
deleteProgram: vi.fn(),
|
|
createBuffer: () => ({}),
|
|
bindBuffer: vi.fn(),
|
|
bufferData: vi.fn(),
|
|
createVertexArray: () => ({}),
|
|
bindVertexArray: vi.fn(),
|
|
enableVertexAttribArray: vi.fn(),
|
|
vertexAttribPointer: vi.fn(),
|
|
vertexAttribDivisor: vi.fn(),
|
|
getUniformLocation: () => ({}),
|
|
createTexture: () => ({}),
|
|
bindTexture: vi.fn(),
|
|
texParameteri: vi.fn(),
|
|
texImage2D: vi.fn(),
|
|
texSubImage2D: vi.fn(),
|
|
generateMipmap: vi.fn(),
|
|
pixelStorei: vi.fn(),
|
|
deleteTexture: vi.fn(),
|
|
deleteBuffer: vi.fn(),
|
|
deleteVertexArray: vi.fn(),
|
|
viewport: vi.fn(),
|
|
clearColor: vi.fn(),
|
|
clear: vi.fn(),
|
|
enable: vi.fn(),
|
|
blendFunc: vi.fn(),
|
|
useProgram: vi.fn(),
|
|
uniform2f: vi.fn(),
|
|
uniform1f: vi.fn(),
|
|
uniform1i: vi.fn(),
|
|
activeTexture: vi.fn(),
|
|
drawArrays: vi.fn(),
|
|
drawArraysInstanced: vi.fn(),
|
|
lineWidth: vi.fn(),
|
|
TEXTURE_2D: 0x0de1,
|
|
TEXTURE0: 0x84c0,
|
|
RGBA: 0x1908,
|
|
UNSIGNED_BYTE: 0x1401,
|
|
LINEAR: 0x2601,
|
|
LINEAR_MIPMAP_LINEAR: 0x2703,
|
|
CLAMP_TO_EDGE: 0x812f,
|
|
TEXTURE_MIN_FILTER: 0x2801,
|
|
TEXTURE_MAG_FILTER: 0x2800,
|
|
TEXTURE_WRAP_S: 0x2802,
|
|
TEXTURE_WRAP_T: 0x2803,
|
|
UNPACK_FLIP_Y_WEBGL: 0x9240,
|
|
UNPACK_PREMULTIPLY_ALPHA_WEBGL: 0x9241,
|
|
COLOR_BUFFER_BIT: 0x4000,
|
|
BLEND: 0x0be2,
|
|
SRC_ALPHA: 0x0302,
|
|
ONE_MINUS_SRC_ALPHA: 0x0303,
|
|
ARRAY_BUFFER: 0x8892,
|
|
STATIC_DRAW: 0x88e4,
|
|
DYNAMIC_DRAW: 0x88e8,
|
|
FLOAT: 0x1406,
|
|
TRIANGLES: 0x0004,
|
|
LINES: 0x0001,
|
|
VERTEX_SHADER: 0x8b31,
|
|
FRAGMENT_SHADER: 0x8b30,
|
|
COMPILE_STATUS: 0x8b81,
|
|
LINK_STATUS: 0x8b82,
|
|
};
|
|
}
|
|
|
|
function makeCanvas(gl) {
|
|
const host = document.createElement("div");
|
|
host.style.cssText = "position:relative;width:400px;height:300px;";
|
|
const canvas = document.createElement("canvas");
|
|
host.appendChild(canvas);
|
|
document.body.appendChild(host);
|
|
Object.defineProperty(canvas, "clientWidth", { value: 400 });
|
|
Object.defineProperty(canvas, "clientHeight", { value: 300 });
|
|
canvas.getBoundingClientRect = () => ({ left: 10, top: 20, width: 400, height: 300 });
|
|
canvas.setPointerCapture = vi.fn();
|
|
canvas.releasePointerCapture = vi.fn();
|
|
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(function (type, opts) {
|
|
if (this === canvas && type === "webgl2") return gl;
|
|
if (type === "2d") {
|
|
return {
|
|
clearRect: vi.fn(),
|
|
drawImage: vi.fn(),
|
|
getImageData: () => ({
|
|
data: new Uint8ClampedArray(128 * 128 * 4).fill(255),
|
|
width: 128,
|
|
height: 128,
|
|
}),
|
|
putImageData: vi.fn(),
|
|
save: vi.fn(),
|
|
restore: vi.fn(),
|
|
setTransform: vi.fn(),
|
|
strokeText: vi.fn(),
|
|
fillText: vi.fn(),
|
|
};
|
|
}
|
|
return null;
|
|
});
|
|
return canvas;
|
|
}
|
|
|
|
describe("networkVisualiserWebGLEngine", () => {
|
|
beforeEach(() => {
|
|
clearSceneGlobals();
|
|
});
|
|
|
|
afterEach(() => {
|
|
clearSceneGlobals();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("isVisualiserWebGLSceneReady requires scene exports", () => {
|
|
expect(isVisualiserWebGLSceneReady()).toBe(false);
|
|
installSceneReadyStubs();
|
|
expect(isVisualiserWebGLSceneReady()).toBe(true);
|
|
});
|
|
|
|
it("canUseVisualiserWebGL is false without scene readiness", () => {
|
|
expect(canUseVisualiserWebGL()).toBe(false);
|
|
});
|
|
|
|
it("truncateWebGLLabel caps long names", () => {
|
|
expect(truncateWebGLLabel("")).toBeNull();
|
|
expect(truncateWebGLLabel(null)).toBeNull();
|
|
expect(truncateWebGLLabel("short")).toBe("short");
|
|
const long = "a".repeat(WEBGL_LABEL_MAX_CHARS + 10);
|
|
const out = truncateWebGLLabel(long);
|
|
expect(out.length).toBe(WEBGL_LABEL_MAX_CHARS);
|
|
expect(out.endsWith("...")).toBe(true);
|
|
});
|
|
|
|
it("collectWebGLLabels follows canvas LOD bands", () => {
|
|
const nodes = new Float32Array([
|
|
0, 0, 50, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 10, 20, 25, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 30, 40,
|
|
25, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
|
]);
|
|
const labelByIndex = ["Local", "eth0", "Alice"];
|
|
const idByIndex = ["me", "eth0", "peer1"];
|
|
const base = { sceneCount: 3, nodes, labelByIndex, idByIndex };
|
|
|
|
expect(collectWebGLLabels({ ...base, zoom: 0.1 })).toEqual([]);
|
|
expect(collectWebGLLabels({ ...base, zoom: 0.3 }).map((l) => l.text)).toEqual(["Local"]);
|
|
expect(
|
|
collectWebGLLabels({ ...base, zoom: 0.3, hoverId: "peer1" })
|
|
.map((l) => l.text)
|
|
.sort()
|
|
).toEqual(["Alice", "Local"]);
|
|
const high = collectWebGLLabels({ ...base, zoom: 0.8 });
|
|
expect(high.map((l) => l.text)).toEqual(["Local", "eth0", "Alice"]);
|
|
expect(high[0].fontSize).toBe(16);
|
|
expect(high[1].fontSize).toBe(11);
|
|
});
|
|
|
|
it("graphToSceneRequest maps me/iface/peer colors and kinds", () => {
|
|
const req = graphToSceneRequest(
|
|
[
|
|
{ id: "me", group: "me", x: 0, y: 0, size: 50, color: { border: "#3b82f6" } },
|
|
{
|
|
id: "Radio",
|
|
group: "interface",
|
|
x: 10,
|
|
y: 20,
|
|
size: 35,
|
|
image: "/assets/images/network-visualiser/interface_connected.png",
|
|
color: { border: "#10b981" },
|
|
},
|
|
{ id: "abcd", group: "announce", x: 30, y: 40, size: 25, color: { border: "#8b5cf6" } },
|
|
],
|
|
[{ from: "me", to: "Radio", width: 3, color: { color: "#10b981", opacity: 1 } }],
|
|
{ width: 640, height: 480, zoom: 1 }
|
|
);
|
|
expect(req.nodes).toHaveLength(3);
|
|
expect(req.nodes[0].kind).toBe(KIND_ME);
|
|
expect(req.nodes[0].fixed).toBe(true);
|
|
expect(req.nodes[1].kind).toBe(KIND_IFACE_ON);
|
|
expect(req.nodes[2].kind).toBe(KIND_PEER);
|
|
expect(req.edges).toHaveLength(1);
|
|
expect(req.edges[0].from).toBe("me");
|
|
expect(req.width).toBe(640);
|
|
});
|
|
|
|
it("graphToSceneRequest keeps zoom 0 so Scene.Set can preserve camera", () => {
|
|
const req = graphToSceneRequest([], [], { width: 100, height: 100, zoom: 0, camX: 12, camY: -4 });
|
|
expect(req.zoom).toBe(0);
|
|
expect(req.cam_x).toBe(12);
|
|
expect(req.cam_y).toBe(-4);
|
|
const def = graphToSceneRequest([], [], { width: 100, height: 100 });
|
|
expect(def.zoom).toBe(1);
|
|
});
|
|
|
|
it("graphToSceneRequest marks disconnected interfaces and discovered peers", () => {
|
|
const req = graphToSceneRequest(
|
|
[
|
|
{
|
|
id: "down",
|
|
group: "interface",
|
|
image: "/assets/images/network-visualiser/interface_disconnected.png",
|
|
color: { border: "#ef4444" },
|
|
},
|
|
{ id: "disc", group: "discovered", color: "#a855f7" },
|
|
],
|
|
[],
|
|
{ width: 100, height: 100, zoom: 1 }
|
|
);
|
|
expect(req.nodes[0].kind).toBe(KIND_IFACE_OFF);
|
|
expect(req.nodes[1].kind).toBe(KIND_DISCOVERED);
|
|
expect(req.nodes[1].r).toBeCloseTo(168 / 255);
|
|
});
|
|
|
|
it("graphToSceneRequest skips incomplete edges and nodes", () => {
|
|
const req = graphToSceneRequest(
|
|
[{ id: "" }, { group: "me" }, { id: "ok", x: 1, y: 2 }],
|
|
[{ from: "a" }, { to: "b" }, { from: "ok", to: "ok" }],
|
|
{ width: 10, height: 10 }
|
|
);
|
|
expect(req.nodes).toHaveLength(1);
|
|
expect(req.nodes[0].id).toBe("ok");
|
|
expect(req.edges).toHaveLength(1);
|
|
expect(req.zoom).toBe(1);
|
|
});
|
|
|
|
it("pointerDistance and midpoint support pinch zoom math", () => {
|
|
const a = { x: 0, y: 0 };
|
|
const b = { x: 30, y: 40 };
|
|
expect(pointerDistance(a, b)).toBe(50);
|
|
expect(pointerMidpoint(a, b)).toEqual({ x: 15, y: 20 });
|
|
});
|
|
|
|
it("pinch zoom factor is distance ratio around midpoint", () => {
|
|
const startA = { x: 100, y: 100 };
|
|
const startB = { x: 200, y: 100 };
|
|
const endA = { x: 80, y: 100 };
|
|
const endB = { x: 220, y: 100 };
|
|
const startDist = pointerDistance(startA, startB);
|
|
const endDist = pointerDistance(endA, endB);
|
|
expect(startDist).toBe(100);
|
|
expect(endDist).toBe(140);
|
|
expect(endDist / startDist).toBeCloseTo(1.4);
|
|
expect(pointerMidpoint(endA, endB)).toEqual({ x: 150, y: 100 });
|
|
});
|
|
});
|
|
|
|
describe("networkVisualiserWebGL textures", () => {
|
|
it("atlasUvForSlot maps grid cells", () => {
|
|
expect(atlasUvForSlot(0)).toEqual({ u: 0, v: 0 });
|
|
expect(atlasUvForSlot(1).u).toBeCloseTo(1 / ATLAS_COLS);
|
|
expect(atlasUvForSlot(ATLAS_COLS).v).toBeCloseTo(1 / ATLAS_ROWS);
|
|
const last = atlasUvForSlot(ATLAS_COLS * ATLAS_ROWS - 1);
|
|
expect(last.u).toBeCloseTo((ATLAS_COLS - 1) / ATLAS_COLS);
|
|
expect(last.v).toBeCloseTo((ATLAS_ROWS - 1) / ATLAS_ROWS);
|
|
});
|
|
|
|
it("resolveVisualiserAssetUrl keeps blob/data/http URLs", () => {
|
|
expect(resolveVisualiserAssetUrl("blob:http://localhost/x")).toBe("blob:http://localhost/x");
|
|
expect(resolveVisualiserAssetUrl("data:image/png;base64,xx")).toBe("data:image/png;base64,xx");
|
|
expect(resolveVisualiserAssetUrl("https://example.com/a.png")).toBe("https://example.com/a.png");
|
|
});
|
|
|
|
it("resolveVisualiserAssetUrl absolutizes root paths", () => {
|
|
const origin = window.location.origin;
|
|
expect(resolveVisualiserAssetUrl("/assets/images/reticulum_logo_512.png")).toBe(
|
|
`${origin}/assets/images/reticulum_logo_512.png`
|
|
);
|
|
});
|
|
|
|
it("mergeSceneNodesWithTextures attaches atlas UVs", () => {
|
|
const scene = new Float32Array(SCENE_NODE_STRIDE);
|
|
scene[0] = 1;
|
|
scene[1] = 2;
|
|
scene[2] = 10;
|
|
scene[3] = 0.1;
|
|
scene[4] = 0.2;
|
|
scene[5] = 0.3;
|
|
scene[6] = 1;
|
|
scene[7] = 3;
|
|
const out = mergeSceneNodesWithTextures(scene, [{ useTex: 1, u: 0.25, v: 0.5 }]);
|
|
expect(out.length).toBe(NODE_STRIDE);
|
|
expect(out[0]).toBe(1);
|
|
expect(out[2]).toBe(10);
|
|
expect(out[7]).toBe(1);
|
|
expect(out[8]).toBe(0.25);
|
|
expect(out[9]).toBe(0.5);
|
|
});
|
|
|
|
it("mergeSceneNodesWithTextures falls back to untextured discs", () => {
|
|
const scene = new Float32Array(SCENE_NODE_STRIDE);
|
|
const out = mergeSceneNodesWithTextures(scene, [{ useTex: 0, u: 0, v: 0 }]);
|
|
expect(out[7]).toBe(0);
|
|
});
|
|
|
|
it("mergeSceneNodesWithTextures handles multiple nodes and reuses scratch", () => {
|
|
const scene = new Float32Array(SCENE_NODE_STRIDE * 2);
|
|
scene[0] = 1;
|
|
scene[SCENE_NODE_STRIDE] = 5;
|
|
const scratch = new Float32Array(NODE_STRIDE * 4);
|
|
const meta = [
|
|
{ useTex: 1, u: 0.1, v: 0.2 },
|
|
{ useTex: 0, u: 0, v: 0 },
|
|
];
|
|
const out = mergeSceneNodesWithTextures(scene, meta, scratch);
|
|
expect(out.length).toBe(NODE_STRIDE * 2);
|
|
expect(out.buffer).toBe(scratch.buffer);
|
|
expect(out[0]).toBe(1);
|
|
expect(out[7]).toBe(1);
|
|
expect(out[NODE_STRIDE]).toBe(5);
|
|
expect(out[NODE_STRIDE + 7]).toBe(0);
|
|
});
|
|
|
|
it("mergeSceneNodesWithTextures tolerates empty input", () => {
|
|
expect(mergeSceneNodesWithTextures(null, []).length).toBe(0);
|
|
expect(mergeSceneNodesWithTextures(new Float32Array(0), []).length).toBe(0);
|
|
});
|
|
|
|
it("tryCreateWebGL2Context returns null for invalid canvas", () => {
|
|
expect(tryCreateWebGL2Context(null)).toBeNull();
|
|
expect(tryCreateWebGL2Context({})).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("createVisualiserWebGLEngine interactions", () => {
|
|
let engine;
|
|
let canvas;
|
|
let gl;
|
|
let zoomAt;
|
|
|
|
beforeEach(() => {
|
|
clearSceneGlobals();
|
|
zoomAt = vi.fn();
|
|
gl = stubGl();
|
|
installSceneReadyStubs({
|
|
meshchatxVisualiserSceneSet: () => JSON.stringify({ ok: true, nodes: 2, edges: 0 }),
|
|
meshchatxVisualiserSceneGetDrawBuffers: () => ({
|
|
ok: true,
|
|
nodes: new Float32Array(SCENE_NODE_STRIDE * 2),
|
|
edges: new Float32Array(0),
|
|
camX: 0,
|
|
camY: 0,
|
|
zoom: 1,
|
|
nodeCount: 2,
|
|
edgeCount: 0,
|
|
}),
|
|
meshchatxVisualiserScenePick: () => null,
|
|
});
|
|
globalThis.meshchatxVisualiserSceneZoomAt = (...args) => zoomAt(...args);
|
|
globalThis.meshchatxVisualiserScenePanBy = vi.fn();
|
|
globalThis.meshchatxVisualiserSceneGetPositions = () => JSON.stringify({ positions: { me: { x: 0, y: 0 } } });
|
|
globalThis.meshchatxVisualiserSceneResize = vi.fn();
|
|
canvas = makeCanvas(gl);
|
|
engine = createVisualiserWebGLEngine(canvas, {
|
|
getLiveLayout: () => false,
|
|
isDark: () => false,
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (engine) {
|
|
engine.destroy();
|
|
engine = null;
|
|
}
|
|
canvas?.parentElement?.remove();
|
|
clearSceneGlobals();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("throws without WASM scene readiness", () => {
|
|
engine.destroy();
|
|
engine = null;
|
|
clearSceneGlobals();
|
|
const c = makeCanvas(stubGl());
|
|
expect(() => createVisualiserWebGLEngine(c)).toThrow(/WASM scene unavailable/);
|
|
c.parentElement?.remove();
|
|
});
|
|
|
|
it("pinch gesture calls SceneZoomAt with distance ratio", () => {
|
|
const fire = (type, props) => {
|
|
const ev = new Event(type, { bubbles: true });
|
|
Object.assign(ev, props);
|
|
canvas.dispatchEvent(ev);
|
|
};
|
|
|
|
// css = client - rect.left/top => 110-10=100, 120-20=100
|
|
fire("pointerdown", { pointerId: 1, pointerType: "touch", button: 0, clientX: 110, clientY: 120 });
|
|
fire("pointerdown", { pointerId: 2, pointerType: "touch", button: 0, clientX: 210, clientY: 120 });
|
|
// dist 100 -> 140 (css x 100 and 240), mid css (170, 100)
|
|
fire("pointermove", { pointerId: 2, pointerType: "touch", button: 0, clientX: 250, clientY: 120 });
|
|
|
|
expect(zoomAt).toHaveBeenCalled();
|
|
const [sx, sy, factor] = zoomAt.mock.calls.at(-1);
|
|
expect(factor).toBeCloseTo(1.4, 5);
|
|
expect(sx).toBeCloseTo(170);
|
|
expect(sy).toBeCloseTo(100);
|
|
expect(globalThis.meshchatxVisualiserScenePanBy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("planet pinch dollies orbit instead of SceneZoomAt", () => {
|
|
engine.setViewMode("planet");
|
|
const fire = (type, props) => {
|
|
const ev = new Event(type, { bubbles: true });
|
|
Object.assign(ev, props);
|
|
canvas.dispatchEvent(ev);
|
|
};
|
|
fire("pointerdown", { pointerId: 1, pointerType: "touch", button: 0, clientX: 110, clientY: 120 });
|
|
fire("pointerdown", { pointerId: 2, pointerType: "touch", button: 0, clientX: 210, clientY: 120 });
|
|
fire("pointermove", { pointerId: 2, pointerType: "touch", button: 0, clientX: 250, clientY: 120 });
|
|
expect(zoomAt).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("wheel zoom calls SceneZoomAt", () => {
|
|
const ev = new Event("wheel", { bubbles: true, cancelable: true });
|
|
Object.assign(ev, { clientX: 60, clientY: 80, deltaY: -100 });
|
|
canvas.dispatchEvent(ev);
|
|
// css: 60-10=50, 80-20=60
|
|
expect(zoomAt).toHaveBeenCalledWith(50, 60, 1.12);
|
|
});
|
|
|
|
it("planet wheel does not call SceneZoomAt", () => {
|
|
engine.setViewMode("planet");
|
|
const ev = new Event("wheel", { bubbles: true, cancelable: true });
|
|
Object.assign(ev, { clientX: 60, clientY: 80, deltaY: -100 });
|
|
canvas.dispatchEvent(ev);
|
|
expect(zoomAt).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("clears light background when WASM draw buffers are not ready", async () => {
|
|
engine.destroy();
|
|
engine = null;
|
|
clearSceneGlobals();
|
|
const gl = stubGl();
|
|
installSceneReadyStubs({
|
|
meshchatxVisualiserSceneSet: () => JSON.stringify({ ok: true, nodes: 0, edges: 0 }),
|
|
meshchatxVisualiserSceneGetDrawBuffers: () => ({ ok: false }),
|
|
});
|
|
globalThis.meshchatxVisualiserSceneResize = vi.fn();
|
|
canvas = makeCanvas(gl);
|
|
engine = createVisualiserWebGLEngine(canvas, {
|
|
getLiveLayout: () => false,
|
|
isDark: () => false,
|
|
});
|
|
await new Promise((resolve) => {
|
|
requestAnimationFrame(() => {
|
|
requestAnimationFrame(resolve);
|
|
});
|
|
});
|
|
expect(gl.clearColor).toHaveBeenCalledWith(0.973, 0.98, 0.988, 1);
|
|
expect(gl.clear).toHaveBeenCalled();
|
|
});
|
|
|
|
it("setGraph and updateNodeImages upload icon textures", async () => {
|
|
const bitmap = { width: 32, height: 32, close: vi.fn() };
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async () => ({
|
|
ok: true,
|
|
blob: async () => new Blob([new Uint8Array([1, 2, 3])], { type: "image/png" }),
|
|
}))
|
|
);
|
|
vi.stubGlobal(
|
|
"createImageBitmap",
|
|
vi.fn(async () => bitmap)
|
|
);
|
|
|
|
engine.setGraph(
|
|
[
|
|
{
|
|
id: "me",
|
|
group: "me",
|
|
label: "Local",
|
|
image: "/assets/images/reticulum_logo_512.png",
|
|
x: 0,
|
|
y: 0,
|
|
},
|
|
{
|
|
id: "peer",
|
|
group: "announce",
|
|
label: "Peer",
|
|
image: "/assets/images/network-visualiser/user.png",
|
|
x: 10,
|
|
y: 10,
|
|
},
|
|
],
|
|
[],
|
|
{ preserveCamera: false, zoom: 1 }
|
|
);
|
|
expect(engine.getCounts()).toEqual({ nodes: 2, edges: 0 });
|
|
expect(engine.getPositions()).toEqual({ me: { x: 0, y: 0 } });
|
|
|
|
await vi.waitFor(() => {
|
|
expect(gl.texSubImage2D).toHaveBeenCalled();
|
|
});
|
|
|
|
const uploadsBefore = gl.texSubImage2D.mock.calls.length;
|
|
engine.updateNodeImages([{ id: "peer", image: "blob:custom-icon" }]);
|
|
await vi.waitFor(() => {
|
|
expect(gl.texSubImage2D.mock.calls.length).toBeGreaterThan(uploadsBefore);
|
|
});
|
|
});
|
|
|
|
it("setGraph applies kind default icons when image missing", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async () => ({
|
|
ok: true,
|
|
blob: async () => new Blob([new Uint8Array([9])], { type: "image/png" }),
|
|
}))
|
|
);
|
|
vi.stubGlobal(
|
|
"createImageBitmap",
|
|
vi.fn(async () => ({ width: 16, height: 16, close: vi.fn() }))
|
|
);
|
|
|
|
engine.setGraph([{ id: "me", group: "me", label: "Me", x: 0, y: 0 }], [], {
|
|
preserveCamera: false,
|
|
zoom: 1,
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(globalThis.fetch).toHaveBeenCalled();
|
|
expect(String(globalThis.fetch.mock.calls[0][0])).toContain("reticulum_logo_512.png");
|
|
});
|
|
});
|
|
|
|
it("sets touch-action none for mobile gestures", () => {
|
|
expect(canvas.style.touchAction).toBe("none");
|
|
});
|
|
});
|