182 lines
7.5 KiB
JavaScript
182 lines
7.5 KiB
JavaScript
import { expect, test } from "bun:test";
|
|
import { runInNewContext } from "node:vm";
|
|
import { clampAgentLauncherPosition, moveAgentLauncher, restoreAgentLauncherPosition } from "../src/lib/canvas/agent-launcher-position";
|
|
|
|
const viewport = { width: 1280, height: 800 };
|
|
const start = { right: 20, bottom: 20 };
|
|
|
|
test("Live2D bounds use actual rectangular dimensions when restoring and dragging", () => {
|
|
const size = { width: 180, height: 240 };
|
|
const small = { width: 320, height: 400 };
|
|
expect(restoreAgentLauncherPosition('{"right":999,"bottom":999}', small, size)).toEqual({ right: 120, bottom: 140 });
|
|
expect(moveAgentLauncher({ start, x: 0, y: 0, dragged: true }, -999, -999, small, size).position).toEqual({ right: 120, bottom: 140 });
|
|
});
|
|
|
|
test("launcher restores a valid position and defaults safely for corrupt preferences", () => {
|
|
expect(restoreAgentLauncherPosition('{"right":240,"bottom":180}', viewport)).toEqual({ right: 240, bottom: 180 });
|
|
for (const raw of [null, "invalid", "null", "{}", '{"right":"20","bottom":20}', '{"right":1e999,"bottom":20}']) {
|
|
expect(restoreAgentLauncherPosition(raw, viewport)).toEqual(start);
|
|
}
|
|
});
|
|
|
|
test("launcher stays within all four edges and clamps after a viewport shrink", () => {
|
|
expect(clampAgentLauncherPosition({ right: -500, bottom: -500 }, viewport)).toEqual(start);
|
|
expect(clampAgentLauncherPosition({ right: 5000, bottom: 5000 }, viewport)).toEqual({ right: 1184, bottom: 704 });
|
|
expect(clampAgentLauncherPosition({ right: 1000, bottom: 500 }, { width: 320, height: 300 })).toEqual({ right: 224, bottom: 204 });
|
|
expect(clampAgentLauncherPosition(start, { width: 90, height: 90 })).toEqual({ right: 7, bottom: 7 });
|
|
});
|
|
|
|
test("small pointer jitter remains a click, but dragging back to the origin remains a drag", () => {
|
|
const gesture = { start, x: 500, y: 500, dragged: false };
|
|
expect(moveAgentLauncher(gesture, 502, 503, viewport)).toEqual({ dragged: false, position: start });
|
|
expect(moveAgentLauncher(gesture, 400, 450, viewport)).toEqual({ dragged: true, position: { right: 120, bottom: 70 } });
|
|
expect(moveAgentLauncher({ ...gesture, dragged: true }, 500, 500, viewport)).toEqual({ dragged: true, position: start });
|
|
});
|
|
|
|
// Execute the production hook with a tiny hook scheduler; only React and browser
|
|
// side effects are substituted, not the pointer/click handlers being tested.
|
|
const build = await Bun.build({
|
|
entrypoints: [new URL("../src/components/canvas/use-agent-launcher-position.ts", import.meta.url).pathname],
|
|
target: "browser",
|
|
format: "cjs",
|
|
plugins: [
|
|
{
|
|
name: "hook-scheduler",
|
|
setup(builder) {
|
|
builder.onResolve({ filter: /^react$/ }, () => ({ path: "react", namespace: "test" }));
|
|
builder.onLoad({ filter: /.*/, namespace: "test" }, () => ({ contents: "export const { useState, useRef, useEffect } = globalThis.hooks;", loader: "js" }));
|
|
},
|
|
},
|
|
],
|
|
});
|
|
expect(build.success).toBe(true);
|
|
const source = await build.outputs[0].text();
|
|
|
|
function harness() {
|
|
const cells = [];
|
|
let cursor = 0;
|
|
let opens = 0;
|
|
const stored = new Map();
|
|
const listeners = new Map();
|
|
const window = { innerWidth: 1280, innerHeight: 800, addEventListener: (name, fn) => listeners.set(name, fn), removeEventListener: (name) => listeners.delete(name) };
|
|
const module = { exports: {} };
|
|
const hooks = {
|
|
useState(initial) {
|
|
const i = cursor++;
|
|
if (!(i in cells)) cells[i] = typeof initial === "function" ? initial() : initial;
|
|
return [
|
|
cells[i],
|
|
(next) => {
|
|
cells[i] = typeof next === "function" ? next(cells[i]) : next;
|
|
},
|
|
];
|
|
},
|
|
useRef(initial) {
|
|
const i = cursor++;
|
|
return (cells[i] ||= { current: initial });
|
|
},
|
|
useEffect(fn) {
|
|
const i = cursor++;
|
|
if (!cells[i]) {
|
|
cells[i] = true;
|
|
fn();
|
|
}
|
|
},
|
|
};
|
|
runInNewContext(source, { module, exports: module.exports, hooks, window, localStorage: { getItem: (key) => stored.get(key) ?? null, setItem: (key, value) => stored.set(key, value) }, console });
|
|
const render = () => {
|
|
cursor = 0;
|
|
return module.exports.useAgentLauncherPosition(() => opens++);
|
|
};
|
|
let current = render();
|
|
let captured = null;
|
|
const target = {
|
|
setPointerCapture: (id) => {
|
|
captured = id;
|
|
},
|
|
hasPointerCapture: (id) => captured === id,
|
|
releasePointerCapture: () => {
|
|
captured = null;
|
|
},
|
|
};
|
|
return {
|
|
fire(name, overrides = {}) {
|
|
const event = { currentTarget: target, pointerId: 1, isPrimary: true, button: 0, clientX: 500, clientY: 500, detail: 1, preventDefault() {}, stopPropagation() {}, ...overrides };
|
|
current.handlers[name](event);
|
|
current = render();
|
|
},
|
|
resize(width, height) {
|
|
window.innerWidth = width;
|
|
window.innerHeight = height;
|
|
listeners.get("resize")();
|
|
current = render();
|
|
},
|
|
get state() {
|
|
return current;
|
|
},
|
|
get opens() {
|
|
return opens;
|
|
},
|
|
get saved() {
|
|
return [...stored.values()].map(JSON.parse);
|
|
},
|
|
get captured() {
|
|
return captured;
|
|
},
|
|
};
|
|
}
|
|
|
|
test("production handlers preserve click and suppress drag-release activation", () => {
|
|
const h = harness();
|
|
h.fire("onPointerDown");
|
|
h.fire("onPointerMove", { clientX: 502 });
|
|
h.fire("onPointerUp", { clientX: 502 });
|
|
h.fire("onClick");
|
|
expect(h.opens).toBe(1);
|
|
h.fire("onPointerDown");
|
|
h.fire("onPointerMove", { clientX: 400, clientY: 450 });
|
|
expect(h.state.dragging).toBe(true);
|
|
h.fire("onPointerUp", { clientX: 400, clientY: 450 });
|
|
h.fire("onClick");
|
|
expect(h.opens).toBe(1);
|
|
expect(h.state.position).toEqual({ right: 120, bottom: 70 });
|
|
expect(h.saved).toEqual([{ right: 120, bottom: 70 }]);
|
|
expect(h.captured).toBe(null);
|
|
h.fire("onClick", { detail: 0 });
|
|
expect(h.opens).toBe(2);
|
|
h.fire("onPointerDown");
|
|
h.fire("onPointerUp");
|
|
h.fire("onClick");
|
|
expect(h.opens).toBe(3);
|
|
});
|
|
|
|
for (const ending of ["onPointerCancel", "onLostPointerCapture"]) {
|
|
test(`production handlers stop safely on ${ending} and ignore other pointers`, () => {
|
|
const h = harness();
|
|
h.fire("onPointerDown", { button: 2 });
|
|
expect(h.captured).toBe(null);
|
|
h.fire("onPointerDown", { pointerType: "touch" });
|
|
h.fire("onPointerMove", { pointerId: 2, clientX: 100 });
|
|
expect(h.state.position).toEqual(start);
|
|
h.fire("onPointerMove", { clientX: 300 });
|
|
h.fire(ending);
|
|
h.fire("onPointerMove", { clientX: 100 });
|
|
h.fire("onClick");
|
|
expect(h.state.position).toEqual({ right: 220, bottom: 20 });
|
|
expect(h.state.dragging).toBe(false);
|
|
expect(h.captured).toBe(null);
|
|
expect(h.opens).toBe(0);
|
|
});
|
|
}
|
|
|
|
test("production handlers support keyboard movement and viewport resizing", () => {
|
|
const h = harness();
|
|
h.fire("onKeyDown", { key: "ArrowLeft" });
|
|
h.fire("onKeyDown", { key: "ArrowUp", shiftKey: true });
|
|
expect(h.state.position).toEqual({ right: 30, bottom: 60 });
|
|
h.resize(100, 100);
|
|
expect(h.state.position).toEqual({ right: 12, bottom: 12 });
|
|
h.fire("onClick", { detail: 0 });
|
|
expect(h.opens).toBe(1);
|
|
});
|