145 lines
7.8 KiB
JavaScript
145 lines
7.8 KiB
JavaScript
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');
|
|
});
|
|
}
|