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