142 lines
5.7 KiB
JavaScript
142 lines
5.7 KiB
JavaScript
import {
|
|
arrayPositionToCanvas,
|
|
canvasPointToArray,
|
|
eventToArrayPosition,
|
|
eventToCanvasPoint,
|
|
fitArrayViewport,
|
|
hitTestMicrophone,
|
|
keyboardNudgeForKey,
|
|
niceGridStep,
|
|
} from "/apps/array-builder/canvas-view.js";
|
|
|
|
function assertNear(actual, expected, tolerance, label) {
|
|
assert(Math.abs(actual - expected) <= tolerance, label + ": expected " + expected + ", received " + actual);
|
|
}
|
|
|
|
function fixture() {
|
|
return {
|
|
aperture: { width: 0.4, height: 0.2 },
|
|
microphones: [
|
|
{ id: "mic-001", position: [-0.1, -0.05, 0] },
|
|
{ id: "mic-002", position: [0.1, 0.05, 0.01] },
|
|
],
|
|
};
|
|
}
|
|
|
|
test("Array canvas fit은 x/y에 동일 축척을 사용하고 좌표를 왕복한다", () => {
|
|
const viewport = fitArrayViewport(800, 420, fixture());
|
|
const point = [0.075, -0.025, 0.012];
|
|
const pixel = arrayPositionToCanvas(point, viewport);
|
|
const roundTrip = canvasPointToArray(pixel, viewport, { z: point[2] });
|
|
assertNear(roundTrip[0], point[0], 1e-12, "x round trip");
|
|
assertNear(roundTrip[1], point[1], 1e-12, "y round trip");
|
|
assertNear(roundTrip[2], point[2], 1e-12, "z preserved");
|
|
const oneCentimeterX = arrayPositionToCanvas([0.01, 0, 0], viewport).x -
|
|
arrayPositionToCanvas([0, 0, 0], viewport).x;
|
|
const oneCentimeterY = arrayPositionToCanvas([0, 0.01, 0], viewport).y -
|
|
arrayPositionToCanvas([0, 0, 0], viewport).y;
|
|
assertNear(Math.abs(oneCentimeterX), Math.abs(oneCentimeterY), 1e-12, "equal x/y scale");
|
|
});
|
|
|
|
test("Array canvas 좌표는 화면 위쪽을 양의 y로 해석한다", () => {
|
|
const viewport = fitArrayViewport(500, 500, fixture());
|
|
const upper = canvasPointToArray(
|
|
{ x: viewport.plot.x + viewport.plot.width / 2, y: viewport.plot.y },
|
|
viewport,
|
|
);
|
|
const lower = canvasPointToArray(
|
|
{ x: viewport.plot.x + viewport.plot.width / 2, y: viewport.plot.y + viewport.plot.height },
|
|
viewport,
|
|
);
|
|
assert(upper[1] > lower[1], "canvas top maps to larger array y");
|
|
assertNear(upper[1], viewport.yMax, 1e-12, "top is yMax");
|
|
assertNear(lower[1], viewport.yMin, 1e-12, "bottom is yMin");
|
|
});
|
|
|
|
test("PointerEvent-like 좌표 변환은 DOM 없이 canvas와 plot 경계에 clamp할 수 있다", () => {
|
|
const viewport = fitArrayViewport(640, 360, fixture());
|
|
const rect = { left: 100, top: 50, width: 640, height: 360 };
|
|
const canvasPoint = eventToCanvasPoint({ clientX: 50, clientY: 500 }, rect, { clamp: true });
|
|
assertEqual(canvasPoint.x, 0, "client x clamps to canvas left");
|
|
assertEqual(canvasPoint.y, 360, "client y clamps to canvas bottom");
|
|
const position = eventToArrayPosition(
|
|
{ clientX: -500, clientY: 900 },
|
|
rect,
|
|
viewport,
|
|
{ clamp: true, clampToCanvas: true, z: 0.02 },
|
|
);
|
|
assertNear(position[0], viewport.xMin, 1e-12, "event x clamps to plot xMin");
|
|
assertNear(position[1], viewport.yMin, 1e-12, "event y clamps to plot yMin");
|
|
assertEqual(position[2], 0.02, "event conversion carries z");
|
|
});
|
|
|
|
test("Array canvas hit test는 반경 안의 가장 가까운 마이크와 채널 index를 돌려준다", () => {
|
|
const arrayDefinition = fixture();
|
|
const viewport = fitArrayViewport(800, 420, arrayDefinition);
|
|
const micPixel = arrayPositionToCanvas(arrayDefinition.microphones[1].position, viewport);
|
|
const hit = hitTestMicrophone(
|
|
{ x: micPixel.x + 3, y: micPixel.y - 2 },
|
|
arrayDefinition.microphones,
|
|
viewport,
|
|
8,
|
|
);
|
|
assertEqual(hit.id, "mic-002", "closest microphone id");
|
|
assertEqual(hit.index, 1, "physical channel index");
|
|
assertNear(hit.distancePx, Math.hypot(3, 2), 1e-12, "pixel distance");
|
|
assertEqual(hitTestMicrophone({ x: 0, y: 0 }, arrayDefinition.microphones, viewport, 2), null, "miss");
|
|
});
|
|
|
|
test("Array canvas hit test는 viewport 변환에서 overflow된 마이크를 건너뛴다", () => {
|
|
const viewport = {
|
|
plot: { x: 0, y: 0 },
|
|
xMin: 0,
|
|
yMax: 100,
|
|
scale: 2,
|
|
};
|
|
const microphones = [
|
|
{ id: "overflow", position: [Number.MAX_VALUE, 0, 0] },
|
|
{ id: "normal", position: [5, 5, 0] },
|
|
];
|
|
assertEqual(
|
|
arrayPositionToCanvas(microphones[0].position, viewport).x,
|
|
Infinity,
|
|
"fixture overflows after viewport conversion",
|
|
);
|
|
assertEqual(
|
|
hitTestMicrophone({ x: 10, y: 190 }, microphones, viewport, 2)?.id,
|
|
"normal",
|
|
"finite microphone remains hittable",
|
|
);
|
|
});
|
|
|
|
test("Array canvas fit은 극단적인 유한 좌표에서도 aperture fallback viewport를 유지한다", () => {
|
|
const arrayDefinition = fixture();
|
|
arrayDefinition.microphones[0].position = [-Number.MAX_VALUE, 0, 0];
|
|
arrayDefinition.microphones[1].position = [Number.MAX_VALUE, 0, 0];
|
|
const viewport = fitArrayViewport(800, 420, arrayDefinition);
|
|
for (const [name, value] of Object.entries({
|
|
scale: viewport.scale,
|
|
metersPerPixel: viewport.metersPerPixel,
|
|
xMin: viewport.xMin,
|
|
xMax: viewport.xMax,
|
|
yMin: viewport.yMin,
|
|
yMax: viewport.yMax,
|
|
})) {
|
|
assert(Number.isFinite(value), name + " remains finite");
|
|
}
|
|
assert(viewport.xMin < 0 && viewport.xMax > 0, "fallback keeps the aperture around the origin");
|
|
});
|
|
|
|
test("Array canvas grid와 keyboard nudge의 순수 helper가 1/2/5 및 y-up 규칙을 따른다", () => {
|
|
const candidates = [niceGridStep(0.008), niceGridStep(0.08), niceGridStep(0.8), niceGridStep(8)];
|
|
for (const step of candidates) {
|
|
const magnitude = 10 ** Math.floor(Math.log10(step));
|
|
const normalized = Math.round(step / magnitude);
|
|
assert([1, 2, 5, 10].includes(normalized), "grid step uses 1/2/5 decade");
|
|
}
|
|
assertEqual(keyboardNudgeForKey("ArrowLeft", 0.002).dx, -0.002, "left nudge");
|
|
assertEqual(keyboardNudgeForKey("ArrowUp", 0.002).dy, 0.002, "up is positive y");
|
|
assertEqual(keyboardNudgeForKey("ArrowDown", 0.002, true).dy, -0.02, "shift multiplies by ten");
|
|
assertEqual(keyboardNudgeForKey("Enter", 0.002), null, "non-arrow key ignored");
|
|
});
|