Files
node_server/tests/bot_functions.test.mjs
2026-10-22 17:07:08 +08:00

292 lines
14 KiB
JavaScript

import assert from 'node:assert/strict';
import {EventEmitter} from 'node:events';
import {createRequire} from 'node:module';
import test from 'node:test';
import {register} from 'tsx/esm/api';
const loader = register({namespace: 'bot-functions-test'});
const tsImport = loader.import;
test.after(() => loader.unregister());
const names = ['chat', 'jump', 'squat_once', 'squat_continue_switch', 'use_direction_key', 'look_at', 'stop_current_function'];
const operations = Object.fromEntries(await Promise.all(names.map(async name => {
const {init} = await tsImport(`../bot/function/bot/${name}.ts`, import.meta.url);
return [name, () => new init()];
})));
const require = createRequire(import.meta.url);
const {Vec3} = createRequire(require.resolve('@wp2508/mineflayer'))('vec3');
function fixture() {
const bot = new EventEmitter();
bot.entity = {position: new Vec3(1, 2, 3), eyeHeight: 1.62, onGround: true, isValid: true};
bot._client = Object.assign(new EventEmitter(), {state: 'play'});
bot.controlState = {};
bot.calls = [];
bot.getControlState = key => bot.controlState[key] === true;
bot.setControlState = (key, enabled) => {
bot.calls.push([key, enabled]);
bot.controlState[key] = enabled;
};
bot.chat = message => { bot.message = message; };
bot.lookAt = async target => { bot.target = target; };
return bot;
}
function clean(bot, client = bot._client) {
for (const event of ['end', 'error', 'physicsTick']) assert.equal(bot.listenerCount(event), 0, event);
for (const event of ['end', 'error']) assert.equal(client.listenerCount(event), 0, `client ${event}`);
}
// Track pending timers as well as using the test runner's deterministic clock.
function clock(t) {
t.mock.timers.enable({apis: ['setTimeout']});
const schedule = globalThis.setTimeout;
const clear = globalThis.clearTimeout;
const pending = new Set();
t.mock.method(globalThis, 'setTimeout', (callback, delay, ...args) => {
const timer = schedule(() => { pending.delete(timer); callback(...args); }, delay);
pending.add(timer);
return timer;
});
t.mock.method(globalThis, 'clearTimeout', timer => { pending.delete(timer); clear(timer); });
return {tick: ms => t.mock.timers.tick(ms), clean: () => assert.equal(pending.size, 0, 'pending timers')};
}
test('chat preserves exact content and validates its single argument without server acknowledgement', async () => {
const bot = fixture();
for (const message of [' hello ', '/help', 'x'.repeat(256), '你好']) {
assert.equal(await operations.chat().run(bot, [message]), true);
assert.equal(bot.message, message);
clean(bot);
}
for (const args of [null, [], ['a', 'b'], [1], [''], [' \t '], ['x'.repeat(257)], ['a\rb'], ['a\nb'], ['a\0b']]) {
delete bot.message;
assert.equal(await operations.chat().run(bot, args), false);
assert.equal(bot.message, undefined);
}
bot.chat = () => { throw Error('send failed'); };
assert.equal(await operations.chat().run(bot, ['hello']), false);
clean(bot);
});
test('pulses hold their own key for one physicsTick and listen before enabling', async t => {
const timer = clock(t);
for (const [name, key] of [['jump', 'jump'], ['squat_once', 'sneak']]) {
const bot = fixture();
bot.controlState.forward = true;
const pending = operations[name]().run(bot, []);
assert.equal(bot.controlState[key], true);
timer.tick(999);
assert.equal(bot.controlState[key], true);
bot.emit('physicsTick');
assert.equal(await pending, true);
assert.deepEqual(bot.calls, [[key, true], [key, false]]);
assert.equal(bot.controlState.forward, true);
clean(bot);
timer.clean();
const synchronous = fixture();
const set = synchronous.setControlState;
synchronous.setControlState = (control, enabled) => {
set(control, enabled);
if (enabled) synchronous.emit('physicsTick');
};
assert.equal(await operations[name]().run(synchronous, []), true);
assert.equal(synchronous.controlState[key], false);
clean(synchronous);
timer.clean();
}
});
test('pulses time out after one second and reject held keys, airborne jump and extra args', async t => {
const timer = clock(t);
for (const [name, key] of [['jump', 'jump'], ['squat_once', 'sneak']]) {
const bot = fixture();
const pending = operations[name]().run(bot, []);
timer.tick(1000);
assert.equal(await pending, false);
assert.equal(bot.controlState[key], false);
clean(bot);
timer.clean();
bot.controlState[key] = true;
bot.calls = [];
assert.equal(await operations[name]().run(bot, []), false);
for (const args of [null, [false], [1]]) assert.equal(await operations[name]().run(bot, args), false);
assert.deepEqual(bot.calls, []);
clean(bot);
}
const bot = fixture();
bot.entity.onGround = false;
assert.equal(await operations.jump().run(bot, []), false);
assert.deepEqual(bot.calls, []);
timer.clean();
});
test('persistent sneak is explicit and idempotent and survives a concurrent squat pulse', async t => {
const timer = clock(t);
const bot = fixture();
const fn = operations.squat_continue_switch();
for (const enabled of [true, true, false, false]) {
assert.equal(await fn.run(bot, [enabled]), true);
assert.equal(bot.controlState.sneak, enabled);
clean(bot);
}
for (const args of [null, [], [0], ['true'], [undefined], [true, false]])
assert.equal(await fn.run(bot, args), false);
const pending = operations.squat_once().run(bot, []);
assert.equal(await fn.run(bot, [true]), true);
bot.emit('physicsTick');
assert.equal(await pending, true);
assert.equal(bot.controlState.sneak, true);
assert.equal(await operations.squat_once().run(bot, []), false);
assert.equal(bot.controlState.sneak, true);
assert.equal(await fn.run(bot, [false]), true);
clean(bot);
timer.clean();
});
test('directions use exact millisecond durations, reject held keys and leave unrelated keys alone', async t => {
const timer = clock(t);
for (const direction of ['forward', 'back', 'left', 'right']) {
for (const duration of [1, 5000]) {
const bot = fixture();
bot.controlState.sneak = true;
const pending = operations.use_direction_key().run(bot, [direction, duration]);
assert.equal(bot.controlState[direction], true);
bot.emit('physicsTick');
timer.tick(duration - 1);
assert.equal(bot.controlState[direction], true);
assert.equal(await operations.use_direction_key().run(bot, [direction, 1]), false);
timer.tick(1);
assert.equal(await pending, true);
assert.deepEqual(bot.calls, [[direction, true], [direction, false]]);
assert.equal(bot.controlState.sneak, true);
clean(bot);
timer.clean();
}
}
const bot = fixture();
for (const args of [null, [], ['forward'], ['forward', 1, 2], ...['jump', 'sneak', 'Forward', '', 1].map(key => [key, 1]),
...[0, -1, 5001, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, '5', null].map(ms => ['forward', ms])])
assert.equal(await operations.use_direction_key().run(bot, args), false);
assert.deepEqual(bot.calls, []);
clean(bot);
timer.clean();
});
test('owned controls release on end, error, identity change, disconnect and synchronous failure', async t => {
const timer = clock(t);
for (const [name, key, args] of [['jump', 'jump', []], ['squat_once', 'sneak', []], ['use_direction_key', 'left', ['left', 20]]]) {
for (const reason of ['end', 'error', 'client-end', 'client-error', 'self', 'client', 'disconnect', 'throw', 'tick-throw']) {
const bot = fixture();
const client = bot._client;
if (reason === 'throw' || reason === 'tick-throw') {
const set = bot.setControlState;
bot.setControlState = (control, enabled) => {
set(control, enabled);
if (enabled) {
if (reason === 'tick-throw') bot.emit('physicsTick');
throw Error('failed');
}
};
}
const pending = operations[name]().run(bot, args);
if (reason === 'end' || reason === 'error') bot.emit(reason, Error('closed'));
if (reason.startsWith('client-')) client.emit(reason.slice(7), Error('closed'));
if (reason === 'self') bot.entity = {...bot.entity};
if (reason === 'client') bot._client = Object.assign(new EventEmitter(), {state: 'play'});
if (reason === 'disconnect') client.state = 'disconnected';
if (['self', 'client', 'disconnect'].includes(reason)) {
bot.emit('physicsTick');
timer.tick(20);
}
assert.equal(await pending, false, `${name}: ${reason}`);
assert.equal(bot.controlState[key], false);
clean(bot, client);
timer.clean();
}
}
const bot = fixture();
bot.setControlState = () => { throw Error('API failure'); };
assert.equal(await operations.squat_continue_switch().run(bot, [true]), false);
clean(bot);
timer.clean();
});
test('look_at constructs a vector from position, accepts decimals and rejects invalid or eye-coincident targets', async t => {
const timer = clock(t);
const bot = fixture();
assert.equal(await operations.look_at().run(bot, [-1.5, 4.25, 8.75]), true);
assert.ok(bot.target instanceof Vec3);
assert.deepEqual(bot.target, new Vec3(-1.5, 4.25, 8.75));
assert.deepEqual(bot.entity.position, new Vec3(1, 2, 3));
for (const args of [null, [], [1, 2], [1, 2, 3, 4], ['1', 2, 3], [Infinity, 2, 3], [1, NaN, 3],
[1, 2, undefined], [1, 2 + bot.entity.eyeHeight, 3]]) {
delete bot.target;
assert.equal(await operations.look_at().run(bot, args), false);
assert.equal(bot.target, undefined);
clean(bot);
timer.clean();
}
});
test('look_at bounds waits and contains late rejections, synchronous errors and session changes', async t => {
const timer = clock(t);
for (const reason of ['timeout', 'end', 'client-end', 'error', 'self', 'client', 'disconnect', 'invalid', 'throw', 'reject']) {
const bot = fixture();
const client = bot._client;
let resolveLook, rejectLook;
bot.lookAt = () => new Promise((resolve, reject) => { resolveLook = resolve; rejectLook = reject; });
if (reason === 'throw') bot.lookAt = () => { throw Error('look failed'); };
if (reason === 'reject') bot.lookAt = () => Promise.reject(Error('look failed'));
const pending = operations.look_at().run(bot, [1.5, 2.5, 3.5]);
if (reason === 'timeout') timer.tick(5000);
if (reason === 'end' || reason === 'error') bot.emit(reason, Error('closed'));
if (reason === 'client-end') client.emit('end');
if (reason === 'self') bot.entity = {...bot.entity};
if (reason === 'client') bot._client = Object.assign(new EventEmitter(), {state: 'play'});
if (reason === 'disconnect') client.state = 'disconnected';
if (reason === 'invalid') bot.entity.isValid = false;
if (['self', 'client', 'disconnect', 'invalid'].includes(reason)) resolveLook();
assert.equal(await pending, false, reason);
clean(bot, client);
timer.clean();
rejectLook?.(Error('late failure'));
await Promise.resolve();
}
});
test('all operations reject stale sessions before side effects', async () => {
for (const [name, args] of [['chat', ['hello']], ['jump', []], ['squat_once', []],
['squat_continue_switch', [true]], ['use_direction_key', ['forward', 1]], ['look_at', [1, 2, 3]]]) {
for (const change of [bot => { bot.entity = null; }, bot => { bot.entity.isValid = false; },
bot => { bot._client.state = 'disconnected'; }]) {
const bot = fixture();
change(bot);
assert.equal(await operations[name]().run(bot, args), false, name);
assert.deepEqual(bot.calls, []);
assert.equal(bot.message, undefined);
assert.equal(bot.target, undefined);
clean(bot);
}
}
});
test('dynamic discovery publishes all bot operations and their exact positional metadata', async () => {
const {get_function_list} = await tsImport('../bot/function/init.ts', import.meta.url);
const category = (await get_function_list()).find(item => item.path === 'bot');
assert.deepEqual(category.function_list.map(item => item.name).sort(), [...names].sort());
const expected = {stop_current_function: [], chat: [['message', 'string']], jump: [], squat_once: [], squat_continue_switch: [['enabled', 'boolean']],
use_direction_key: [['direction', 'string'], ['duration_ms', 'number']], look_at: [['x', 'number'], ['y', 'number'], ['z', 'number']]};
for (const fn of category.function_list) {
assert.equal(fn.i18n.lang, 'zh-CN');
assert.ok(fn.i18n.name && fn.i18n.description);
assert.deepEqual(fn.args.map(arg => [arg.name, arg.type]), expected[fn.name]);
for (const arg of fn.args) {
assert.equal(arg.i18n.lang, 'zh-CN');
assert.ok(arg.i18n.name && arg.i18n.description);
assert.equal(Object.hasOwn(arg, 'default'), false);
}
}
});