Files
ttstd-infinite-canvas/web/test/agent-launcher-position.test.mjs

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);
});