Files
2026-07-12 21:26:08 +08:00

182 lines
5.4 KiB
TypeScript

import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
bindSigmaWheelZoomController,
sigmaViewportCenter,
sigmaWheelInputFromPayload,
sigmaWheelTargetIsZoomControl
} from "../src/render/sigma-wheel-zoom";
import { sigmaWheelZoomRatio } from "../src/render/sigma-zoom";
import type { SigmaGlobalSigmaLike } from "../src/render/sigma-global-types";
describe("Sigma wheel zoom controller", () => {
it("binds one wheel listener and unregisters the same listener on destroy", () => {
const captor = fakeCaptor();
const controller = bindSigmaWheelZoomController({
sigma: sigmaWithCaptor(captor),
root: rootWithRect(200, 100),
isDestroyed: () => false,
currentRatio: () => 1,
onZoomAtPoint: () => undefined
});
assert.equal(captor.onCalls.length, 1);
controller.destroy();
assert.equal(captor.offCalls.length, 1);
assert.equal(captor.offCalls[0]?.listener, captor.onCalls[0]?.listener);
});
it("makes late wheel events no-op after destroy even when captor still invokes the listener", () => {
const captor = fakeCaptor();
let zoomCalls = 0;
let destroyed = false;
bindSigmaWheelZoomController({
sigma: sigmaWithCaptor(captor),
root: rootWithRect(200, 100),
isDestroyed: () => destroyed,
currentRatio: () => 1,
onZoomAtPoint: () => {
zoomCalls += 1;
}
});
destroyed = true;
captor.emit({ original: { deltaY: 120 } });
assert.equal(zoomCalls, 0);
});
it("ignores invalid payloads", () => {
const captor = fakeCaptor();
let zoomCalls = 0;
bindSigmaWheelZoomController({
sigma: sigmaWithCaptor(captor),
root: rootWithRect(200, 100),
isDestroyed: () => false,
currentRatio: () => 1,
onZoomAtPoint: () => {
zoomCalls += 1;
}
});
captor.emit({});
assert.equal(zoomCalls, 0);
});
it("parses delta and pointer fallbacks consistently", () => {
assert.deepEqual(
sigmaWheelInputFromPayload({ x: 5, y: 6, delta: -10, original: { deltaY: 120, deltaMode: 1 } }, { x: 50, y: 60 }),
{ point: { x: 5, y: 6 }, delta: { deltaY: 120, deltaMode: 1 } }
);
assert.deepEqual(
sigmaWheelInputFromPayload({ delta: -2 }, { x: 50, y: 60 }),
{ point: { x: 50, y: 60 }, delta: { deltaY: 240, deltaMode: 0 } }
);
assert.equal(sigmaWheelInputFromPayload({ original: { deltaY: Number.NaN } }, { x: 0, y: 0 }), null);
});
it("prevents zoom-control wheel events without zooming", () => {
const captor = fakeCaptor();
let zoomCalls = 0;
let prevented = 0;
bindSigmaWheelZoomController({
sigma: sigmaWithCaptor(captor),
root: rootWithRect(200, 100),
isDestroyed: () => false,
currentRatio: () => 1,
onZoomAtPoint: () => {
zoomCalls += 1;
}
});
captor.emit({
original: {
deltaY: 120,
target: { closest: (selector: string) => selector === "[data-control=\"sigma-zoom\"]" }
},
preventSigmaDefault: () => {
prevented += 1;
}
});
assert.equal(sigmaWheelTargetIsZoomControl(captor.lastPayload), true);
assert.equal(prevented, 1);
assert.equal(zoomCalls, 0);
});
it("uses viewport center and reports thrown zoom errors", () => {
const captor = fakeCaptor();
const errors: unknown[] = [];
const points: Array<{ x: number; y: number }> = [];
bindSigmaWheelZoomController({
sigma: sigmaWithCaptor(captor),
root: rootWithRect(200, 100),
isDestroyed: () => false,
currentRatio: () => 1,
onZoomAtPoint: (point) => {
points.push(point);
throw new Error("zoom failed");
},
onFatalError: (error) => errors.push(error)
});
captor.emit({ original: { deltaY: 120 } });
assert.deepEqual(sigmaViewportCenter(rootWithRect(200, 100)), { x: 100, y: 50 });
assert.deepEqual(points, [{ x: 100, y: 50 }]);
assert.equal((errors[0] as Error).message, "zoom failed");
});
it("passes the next wheel ratio to the zoom callback", () => {
const captor = fakeCaptor();
let nextRatio = 0;
bindSigmaWheelZoomController({
sigma: sigmaWithCaptor(captor),
root: rootWithRect(200, 100),
isDestroyed: () => false,
currentRatio: () => 1.2,
onZoomAtPoint: (_point, ratio) => {
nextRatio = ratio;
}
});
captor.emit({ original: { deltaY: 120 } });
assert.equal(nextRatio, sigmaWheelZoomRatio(1.2, { deltaY: 120, deltaMode: 0 }));
});
});
function fakeCaptor() {
const state = {
onCalls: [] as Array<{ event: "wheel"; listener: (payload?: unknown) => void }>,
offCalls: [] as Array<{ event: "wheel"; listener: (payload?: unknown) => void }>,
lastPayload: undefined as unknown,
on(event: "wheel", listener: (payload?: unknown) => void) {
state.onCalls.push({ event, listener });
},
off(event: "wheel", listener: (payload?: unknown) => void) {
state.offCalls.push({ event, listener });
},
emit(payload: unknown) {
state.lastPayload = payload;
state.onCalls[0]?.listener(payload);
}
};
return state;
}
function sigmaWithCaptor(captor: ReturnType<typeof fakeCaptor>): SigmaGlobalSigmaLike {
return {
getMouseCaptor: () => captor
};
}
function rootWithRect(width: number, height: number): HTMLElement {
return {
getBoundingClientRect: () => ({ width, height })
} as HTMLElement;
}