chore: initial commit

This commit is contained in:
2026-10-22 17:07:08 +08:00
commit 0acef64f48
200 changed files with 35546 additions and 0 deletions
+144
View File
@@ -0,0 +1,144 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {tsImport} from 'tsx/esm/api';
const {compile_workflow} = await tsImport('../bot/workflow/runtime/compile.ts', import.meta.url);
const {execute} = await tsImport('../bot/workflow/node/condition_node.ts', import.meta.url);
const {create_event_handler} = await tsImport('../bot/workflow/start_node/event_node.ts', import.meta.url);
const literal = value => ({kind: 'literal', value});
const ref = (...path) => ({kind: 'ref', root: 'trigger', path});
const eq = (left, value) => ({op: 'eq', left, right: literal(value)});
const leaf = name => eq(ref(name), true);
const group = (op, ...items) => ({op, items});
const state = trigger => ({trigger, vars: {}, outputs: {}, last_result: null, inputs: {}, retry_counts: {}});
const truth_table = Array.from({length: 8}, (_, bits) => ({
A: Boolean(bits & 4), B: Boolean(bits & 2), C: Boolean(bits & 1),
}));
function definition(condition, start = {type: 'common', auto_start: 'never'}) {
return {
schema_version: 1, id: 'conditions', name: 'Conditions', enabled: true, start,
entry: 'check', nodes: [{id: 'check', type: 'condition', config: {condition}}], edges: [],
initial_vars: {}, policy: {on_busy: 'skip', max_queue: 0, max_steps: 10, timeout_ms: 5000},
};
}
async function check_result(node, trigger, expected) {
const result = await execute(node, state(trigger), new AbortController().signal);
assert.deepEqual(result, {port: expected ? 'true' : 'false', result: {status: 'success', output: expected}});
}
test('compiled condition node requires all three AND leaves', async t => {
const graph = compile_workflow(definition(group('and', leaf('A'), leaf('B'), leaf('C'))));
for (const trigger of truth_table) await t.test(JSON.stringify(trigger), async () => {
await check_result(graph.nodes.get('check'), trigger, trigger.A && trigger.B && trigger.C);
});
});
test('compiled condition node OR matches the middle leaf and rejects no match', async t => {
const graph = compile_workflow(definition(group('or', leaf('A'), leaf('B'), leaf('C'))));
for (const trigger of [{A: false, B: true, C: false}, {A: false, B: false, C: false}]) {
await t.test(JSON.stringify(trigger), async () => {
await check_result(graph.nodes.get('check'), trigger, trigger.B);
});
}
});
test('compiled condition node preserves mixed nested (A AND B) OR C semantics', async t => {
const condition = group('or', group('and', leaf('A'), leaf('B')), leaf('C'));
const graph = compile_workflow(definition(condition));
for (const trigger of truth_table) await t.test(JSON.stringify(trigger), async () => {
await check_result(graph.nodes.get('check'), trigger, (trigger.A && trigger.B) || trigger.C);
});
});
test('compiled condition node does not resolve irrelevant missing references', async t => {
const missing = eq(ref('missing', 'value'), true);
for (const [label, condition, expected] of [
['AND stops at false', group('and', eq(literal(false), true), missing, missing), false],
['OR stops at true', group('or', eq(literal(true), true), missing, missing), true],
['nested OR skips missing AND leaf', group('or', group('and', leaf('A'), missing), leaf('C')), true],
['NOT preserves short circuit', {op: 'not', item: group('and', leaf('A'), missing)}, true],
]) await t.test(label, async () => {
const graph = compile_workflow(definition(condition));
await check_result(graph.nodes.get('check'), {A: false, C: true}, expected);
});
for (const op of ['and', 'or']) await t.test(`${op} resolves a relevant missing leaf`, async () => {
const condition = group(op, eq(literal(op === 'and'), true), missing);
const graph = compile_workflow(definition(condition));
await assert.rejects(() => execute(graph.nodes.get('check'), state({}), new AbortController().signal),
{code: 'VALUE_NOT_FOUND'});
});
});
for (const event of ['message', 'chat']) {
const args = (username, message) => event === 'message'
? [{json: {translate: 'chat.type.text', with: [{text: username}, {text: message}]}}, 'chat', 'sender-id', true]
: [username, message];
const event_definition = condition => compile_workflow(definition(eq(literal(true), true),
{type: 'event', event, condition})).definition;
test(`compiled ${event} event requires all three normalized field checks`, () => {
const condition = group('and', eq(ref('username'), 'Alice'),
{op: 'contains', left: ref('message'), right: literal('ready')}, eq(ref('event'), event));
const fired = [];
const handler = create_event_handler(event_definition(condition), trigger => fired.push(trigger));
handler(...args('Bob', 'ready'));
handler(...args('Alice', 'waiting'));
assert.deepEqual(fired, []);
handler(...args('Alice', 'ready now'));
assert.equal(fired.length, 1);
assert.equal(fired[0].event, event);
assert.equal(fired[0].username, 'Alice');
assert.equal(fired[0].message, 'ready now');
if (event === 'message') {
assert.equal(fired[0].plainText, '<Alice> ready now');
assert.equal(fired[0].position, 'chat');
assert.equal(fired[0].sender, 'sender-id');
assert.equal(fired[0].verified, true);
} else assert.deepEqual(fired[0].args, ['Alice', 'ready now']);
const wrong_event = group('and', ...condition.items.slice(0, 2), eq(ref('event'), 'other'));
create_event_handler(event_definition(wrong_event), trigger => fired.push(trigger))(...args('Alice', 'ready now'));
assert.equal(fired.length, 1, 'the third check must also prevent firing');
});
test(`compiled ${event} event OR matches the middle check and rejects no match`, () => {
const condition = group('or', eq(ref('username'), 'Alice'), eq(ref('message'), 'ready'),
eq(ref('event'), 'other'));
const fired = [];
const handler = create_event_handler(event_definition(condition), trigger => fired.push(trigger));
handler(...args('Bob', 'waiting'));
assert.deepEqual(fired, []);
handler(...args('Bob', 'ready'));
assert.equal(fired.length, 1);
assert.equal(fired[0].username, 'Bob');
assert.equal(fired[0].message, 'ready');
});
test(`compiled ${event} event preserves mixed nested conditions`, () => {
const condition = group('or', group('and', eq(ref('username'), 'Alice'), eq(ref('message'), 'ready')),
eq(ref('message'), 'override'));
const fired = [];
const handler = create_event_handler(event_definition(condition), trigger => fired.push(trigger));
handler(...args('Alice', 'waiting'));
handler(...args('Bob', 'ready'));
assert.deepEqual(fired, []);
handler(...args('Alice', 'ready'));
handler(...args('Bob', 'override'));
assert.deepEqual(fired.map(({username, message}) => [username, message]), [['Alice', 'ready'], ['Bob', 'override']]);
});
test(`compiled ${event} event short circuits missing references and skips relevant failures`, () => {
const missing = eq(ref('missing', 'value'), true);
const fired = [];
const handler = create_event_handler(event_definition(group('or',
group('and', eq(ref('username'), 'Alice'), missing), eq(ref('message'), 'override'), missing)),
trigger => fired.push(trigger));
handler(...args('Bob', 'override'));
assert.equal(fired.length, 1, 'irrelevant missing refs must not suppress a matching event');
assert.doesNotThrow(() => handler(...args('Alice', 'override')));
assert.doesNotThrow(() => handler(...args('Bob', 'waiting')));
assert.equal(fired.length, 1, 'relevant missing refs must skip the occurrence');
});
}