#!/usr/bin/env node
/**
 * VL Workflow Engine — Comprehensive Test Suite
 * Tests all node types, complex workflows, edge cases per Spec v3.15
 */

const { Engine, ExpressionEvaluator, ExecutionContext, ChildExecutionContext,
        Registry, parseParamDeclaration, parseVariableDeclaration, parseServiceSignature,
        RunEventType, ExecutionStatus, ParallelErrorStrategy,
        toBool, toFloat, isV310OrLater, buildErrorMap, LLMError,
        applyOutputMapping, emitEvent } = require('../index');

let passed = 0, failed = 0, skipped = 0;
const failures = [];

function test(name, fn) {
  try {
    const result = fn();
    if (result && typeof result.then === 'function') {
      return result.then(() => {
        console.log(`  ✓ ${name}`);
        passed++;
      }).catch(e => {
        console.log(`  ✗ ${name}: ${e.message}`);
        failures.push({ name, error: e.message });
        failed++;
      });
    }
    console.log(`  ✓ ${name}`);
    passed++;
  } catch (e) {
    console.log(`  ✗ ${name}: ${e.message}`);
    failures.push({ name, error: e.message });
    failed++;
  }
}

function assert(cond, msg) { if (!cond) throw new Error(msg || 'Assertion failed'); }
function assertEqual(a, b, msg) {
  if (a !== b) throw new Error(msg || `Expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}`);
}
function assertDeepEqual(a, b, msg) {
  const as = JSON.stringify(a), bs = JSON.stringify(b);
  if (as !== bs) throw new Error(msg || `Expected ${bs}, got ${as}`);
}

// Helper: create a simple mock adapter
function mockAdapters(overrides = {}) {
  return {
    service: { call: async (name, params) => overrides.serviceResult || { ok: true } },
    api: { call: async (apiDef, params) => overrides.apiResult || { status: 'ok' } },
    component: { call: async (id, params) => overrides.componentResult || {} },
    llm: { call: async (params, onToken, callbacks) => overrides.llmResult || { content: 'hello', usage: { input_tokens: 10, output_tokens: 5 }, model: 'claude-opus-4-6' } },
    file: {
      write: async (path, content, mode) => { (overrides.writtenFiles || []).push({ path, content: content.toString(), mode }); },
      read: async (path) => Buffer.from(overrides.fileContent || ''),
      unzip: async (data) => overrides.unzipEntries || []
    },
    doc: { get: async (id) => overrides.docs?.[id] || `Doc ${id} content` },
    ...overrides.adapterOverrides
  };
}

// Helper: collect events
function collectEvents(ctx) {
  const events = [];
  ctx.onEvent(e => events.push(e));
  return events;
}

async function runTests() {
  // ═══════════════════════════════════════════════════════════════
  console.log('\n══ Expression Evaluator — Advanced ══');

  await test('multiplication and division', () => {
    const ctx = new ExecutionContext({ workflowID: 'test', params: { x: 12 } });
    const ev = new ExpressionEvaluator(ctx);
    assertEqual(ev.evaluateValue('=x * 3'), 36);
    assertEqual(ev.evaluateValue('=x / 4'), 3);
  });

  await test('operator precedence: * before +', () => {
    const ctx = new ExecutionContext({ workflowID: 'test', params: {} });
    const ev = new ExpressionEvaluator(ctx);
    assertEqual(ev.evaluateValue('=2 + 3 * 4'), 14);
  });

  await test('parenthesized expressions', () => {
    const ctx = new ExecutionContext({ workflowID: 'test', params: {} });
    const ev = new ExpressionEvaluator(ctx);
    assertEqual(ev.evaluateValue('=(2 + 3) * 4'), 20);
  });

  await test('nested ternary', () => {
    const ctx = new ExecutionContext({ workflowID: 'test', params: { x: 2 } });
    const ev = new ExpressionEvaluator(ctx);
    assertEqual(ev.evaluateValue('=x == 1 ? "one" : x == 2 ? "two" : "other"'), 'two');
  });

  await test('comparison operators: >, <, >=, <=', () => {
    const ctx = new ExecutionContext({ workflowID: 'test', params: { x: 10 } });
    const ev = new ExpressionEvaluator(ctx);
    assertEqual(ev.evaluateValue('=x > 5'), true);
    assertEqual(ev.evaluateValue('=x < 5'), false);
    assertEqual(ev.evaluateValue('=x >= 10'), true);
    assertEqual(ev.evaluateValue('=x <= 9'), false);
  });

  await test('strict equality ===', () => {
    const ctx = new ExecutionContext({ workflowID: 'test', params: { x: 5 } });
    const ev = new ExpressionEvaluator(ctx);
    assertEqual(ev.evaluateValue('=x === 5'), true);
    // "5" == 5 is true, but "5" === 5 is false
    const ctx2 = new ExecutionContext({ workflowID: 'test', params: { x: '5' } });
    const ev2 = new ExpressionEvaluator(ctx2);
    assertEqual(ev2.evaluateValue('=x === 5'), false);
    assertEqual(ev2.evaluateValue('=x == 5'), true);
  });

  await test('not equal != and !==', () => {
    const ctx = new ExecutionContext({ workflowID: 'test', params: { x: 5 } });
    const ev = new ExpressionEvaluator(ctx);
    assertEqual(ev.evaluateValue('=x != 3'), true);
    assertEqual(ev.evaluateValue('=x !== 5'), false);
  });

  await test('null/nil literal', () => {
    const ctx = new ExecutionContext({ workflowID: 'test' });
    const ev = new ExpressionEvaluator(ctx);
    assertEqual(ev.evaluateValue('=null'), null);
    assertEqual(ev.evaluateValue('=nil'), null);
  });

  await test('JSON object literal', () => {
    const ctx = new ExecutionContext({ workflowID: 'test' });
    const ev = new ExpressionEvaluator(ctx);
    const result = ev.evaluateValue('={"a":1,"b":"hello"}');
    assertEqual(result.a, 1);
    assertEqual(result.b, 'hello');
  });

  await test('JSON array literal', () => {
    const ctx = new ExecutionContext({ workflowID: 'test' });
    const ev = new ExpressionEvaluator(ctx);
    const result = ev.evaluateValue('=[1,2,3]');
    assertEqual(result.length, 3);
    assertEqual(result[1], 2);
  });

  await test('SYSVAR reference', () => {
    const ctx = new ExecutionContext({ workflowID: 'test', systemVars: { apiKey: 'secret123' } });
    const ev = new ExpressionEvaluator(ctx);
    assertEqual(ev.evaluateValue('=SYSVAR.apiKey'), 'secret123');
  });

  await test('_local variables (_item, _index)', () => {
    const ctx = new ExecutionContext({ workflowID: 'test' });
    ctx.setVariable('_item', { name: 'test', value: 42 });
    ctx.setVariable('_index', 3);
    const ev = new ExpressionEvaluator(ctx);
    assertEqual(ev.evaluateValue('=_item.name'), 'test');
    assertEqual(ev.evaluateValue('=_item.value'), 42);
    assertEqual(ev.evaluateValue('=_index'), 3);
  });

  await test('string concatenation with variables', () => {
    const ctx = new ExecutionContext({ workflowID: 'test', params: { name: 'World' } });
    const ev = new ExpressionEvaluator(ctx);
    assertEqual(ev.evaluateValue('="Hello, " + name + "!"'), 'Hello, World!');
  });

  await test('complex expression: logical + comparison', () => {
    const ctx = new ExecutionContext({ workflowID: 'test', params: { age: 25, hasLicense: true } });
    const ev = new ExpressionEvaluator(ctx);
    assertEqual(ev.evaluateValue('=age >= 18 && hasLicense'), true);
    assertEqual(ev.evaluateValue('=age < 18 || !hasLicense'), false);
  });

  await test('setVariable with array index via expression', () => {
    const ctx = new ExecutionContext({ workflowID: 'test', variables: { '$arr': [null, null, null] } });
    ctx.setVariable('_index', 1);
    const ev = new ExpressionEvaluator(ctx);
    ev.setVariable('$arr[_index]', 'value_at_1');
    assertEqual(ctx.variables['$arr'][1], 'value_at_1');
  });

  await test('setVariable auto-creates intermediate objects', () => {
    const ctx = new ExecutionContext({ workflowID: 'test', variables: {} });
    const ev = new ExpressionEvaluator(ctx);
    ev.setVariable('$data', {});
    ev.setVariable('$data.nested.deep', 42);
    assertEqual(ctx.variables['$data'].nested.deep, 42);
  });

  await test('setVariable auto-creates array with numeric index', () => {
    const ctx = new ExecutionContext({ workflowID: 'test', variables: { '$list': null } });
    const ev = new ExpressionEvaluator(ctx);
    ev.setVariable('$list[0]', 'first');
    ev.setVariable('$list[1]', 'second');
    assert(Array.isArray(ctx.variables['$list']), '$list should be array');
    assertEqual(ctx.variables['$list'][0], 'first');
    assertEqual(ctx.variables['$list'][1], 'second');
  });

  await test('type conversion: OBJECT var from string', () => {
    const ctx = new ExecutionContext({
      workflowID: 'test',
      variables: { '$obj': null },
      varTypes: { '$obj': 'OBJECT' }
    });
    const ev = new ExpressionEvaluator(ctx);
    ev.setVariable('$obj', '{"a":1}');
    assertDeepEqual(ctx.variables['$obj'], { a: 1 });
  });

  await test('type conversion: STRING var from object', () => {
    const ctx = new ExecutionContext({
      workflowID: 'test',
      variables: { '$str': null },
      varTypes: { '$str': 'STRING' }
    });
    const ev = new ExpressionEvaluator(ctx);
    ev.setVariable('$str', { a: 1 });
    assertEqual(ctx.variables['$str'], '{"a":1}');
  });

  await test('toBool edge cases', () => {
    assertEqual(toBool(null), false);
    assertEqual(toBool(undefined), false);
    assertEqual(toBool(0), false);
    assertEqual(toBool(''), false);
    assertEqual(toBool(false), false);
    assertEqual(toBool(1), true);
    assertEqual(toBool('hello'), true);
    assertEqual(toBool([]), true);  // empty array is truthy
    assertEqual(toBool({}), true);  // empty object is truthy
  });

  await test('toFloat edge cases', () => {
    assertEqual(toFloat(42), 42);
    assertEqual(toFloat('3.14'), 3.14);
    assertEqual(toFloat('abc'), 0);
    assertEqual(toFloat(true), 1);
    assertEqual(toFloat(false), 0);
    assertEqual(toFloat(null), 0);
    assertEqual(toFloat(undefined), 0);
  });

  // ═══════════════════════════════════════════════════════════════
  console.log('\n══ Registry — Advanced ══');

  await test('parseServiceSignature with multiple params and returns', () => {
    const s = parseServiceSignature('ApprovalService(form(OBJECT), policy(STRING)) RETURN decision(STRING), comment(STRING)');
    assertEqual(s.name, 'ApprovalService');
    assertEqual(s.parameters.length, 2);
    assertEqual(s.returns.length, 2);
    assertEqual(s.parameters[1].name, 'policy');
    assertEqual(s.returns[0].name, 'decision');
  });

  await test('Registry validates duplicate services', () => {
    const { validateRegistry } = require('../lib/registry');
    const errors = validateRegistry({
      services: ['Svc1(a(STRING)) RETURN ok(BOOL)', 'Svc1(b(INT)) RETURN ok(BOOL)'],
      params: [], vars: []
    });
    assert(errors.some(e => e.includes('Duplicate')), 'Should detect duplicate services');
  });

  await test('Registry validates API methods', () => {
    const { validateRegistry } = require('../lib/registry');
    const errors = validateRegistry({
      apis: [{ id: 'bad', method: 'INVALID', url: '/test' }],
      params: [], vars: [], services: []
    });
    assert(errors.some(e => e.includes('method')), 'Should detect invalid API method');
  });

  await test('Registry schema lookup', () => {
    const reg = new Registry({
      schemas: {
        'PlanSchema': { type: 'object', properties: { title: { type: 'string' } } }
      }
    });
    assert(reg.hasSchema('PlanSchema'));
    assert(!reg.hasSchema('NonExistent'));
    const schema = reg.getSchema('PlanSchema');
    assertEqual(schema.type, 'object');
  });

  await test('Registry file path validation', () => {
    const reg = new Registry({
      files: {
        inputs: ['Process/PRD.json', 'Process/Rules/*'],
        artifacts: ['Process/Artifacts/*']
      }
    });
    assert(reg.isInputPathAllowed('Process/PRD.json'));
    assert(reg.isInputPathAllowed('Process/Rules/rule1.txt'));
    assert(!reg.isInputPathAllowed('Process/Other/file.txt'));
    assert(reg.isArtifactPathAllowed('Process/Artifacts/out.json'));
  });

  // ═══════════════════════════════════════════════════════════════
  console.log('\n══ Engine Validation ══');

  await test('validate: unsupported version', () => {
    const engine = new Engine({ version: '2.0', name: 'test', registry: { params: [], vars: [] }, steps: [{ id: 'Stop_End' }] });
    const errors = engine.validate();
    assert(errors.some(e => e.includes('Unsupported version')));
  });

  await test('validate: missing name', () => {
    const engine = new Engine({ version: '3.15', registry: { params: [], vars: [] }, steps: [{ id: 'Stop_End' }] });
    const errors = engine.validate();
    assert(errors.some(e => e.includes('name')));
  });

  await test('validate: empty steps', () => {
    const engine = new Engine({ version: '3.15', name: 'test', registry: { params: [], vars: [] }, steps: [] });
    const errors = engine.validate();
    assert(errors.some(e => e.includes('step')));
  });

  await test('validate: duplicate step IDs', () => {
    const engine = new Engine({
      version: '3.15', name: 'test',
      registry: { params: [], vars: [] },
      steps: [{ id: 'Set_001', target: '$x', value: '=1' }, { id: 'Set_001', target: '$y', value: '=2' }]
    });
    const errors = engine.validate();
    assert(errors.some(e => e.includes('Duplicate')));
  });

  await test('validate: Stop_* with next should error', () => {
    const engine = new Engine({
      version: '3.15', name: 'test',
      registry: { params: [], vars: [] },
      steps: [{ id: 'Stop_End', next: 'Set_001' }, { id: 'Set_001', target: '$x', value: '=1' }]
    });
    const errors = engine.validate();
    assert(errors.some(e => e.includes('Stop_*')));
  });

  await test('validate: unknown step reference in next', () => {
    const engine = new Engine({
      version: '3.15', name: 'test',
      registry: { params: [], vars: [] },
      steps: [{ id: 'Set_001', target: '$x', value: '=1', next: 'NonExistent' }]
    });
    const errors = engine.validate();
    assert(errors.some(e => e.includes('NonExistent')));
  });

  await test('validate: RETURN is valid next', () => {
    const engine = new Engine({
      version: '3.15', name: 'test',
      registry: { params: [], vars: [] },
      steps: [{ id: 'Set_001', target: '$x', value: '=1', next: 'RETURN' }]
    });
    const errors = engine.validate();
    assert(!errors.some(e => e.includes('RETURN')));
  });

  await test('validate: custom handler bypasses type check', () => {
    const engine = new Engine({
      version: '3.15', name: 'test',
      registry: { params: [], vars: [] },
      steps: [{ id: 'Custom_DoSomething', next: 'Stop_End' }, { id: 'Stop_End' }]
    }, { customHandlers: { Custom: async () => {} } });
    const errors = engine.validate();
    assert(!errors.some(e => e.includes('Unknown step type')));
  });

  // ═══════════════════════════════════════════════════════════════
  console.log('\n══ Set_* Step — Advanced ══');

  await test('Set_* with expression computation', async () => {
    const engine = new Engine({
      version: '3.15', name: 'test',
      registry: { params: ['a(INT)', 'b(INT)'], vars: ['$sum(INT)', '$product(INT)'] },
      steps: [
        { id: 'Set_Sum', target: '$sum', value: '=a + b', next: 'Set_Product' },
        { id: 'Set_Product', target: '$product', value: '=a * b', next: 'Stop_End' },
        { id: 'Stop_End' }
      ]
    });
    const ctx = await engine.execute({ a: 7, b: 3 });
    assertEqual(ctx.variables['$sum'], 10);
    assertEqual(ctx.variables['$product'], 21);
  });

  await test('Set_* deep path write', async () => {
    const engine = new Engine({
      version: '3.15', name: 'test',
      registry: { params: [], vars: ['$config(OBJECT)'] },
      steps: [
        { id: 'Set_Init', target: '$config', value: '={}', next: 'Set_Nested' },
        { id: 'Set_Nested', target: '$config.db.host', value: '="localhost"', next: 'Set_Port' },
        { id: 'Set_Port', target: '$config.db.port', value: '=5432', next: 'Stop_End' },
        { id: 'Stop_End' }
      ]
    });
    const ctx = await engine.execute({});
    assertEqual(ctx.variables['$config'].db.host, 'localhost');
    assertEqual(ctx.variables['$config'].db.port, 5432);
  });

  // ═══════════════════════════════════════════════════════════════
  console.log('\n══ Branch_* — Advanced ══');

  await test('Branch_* with multiple cases', async () => {
    const engine = new Engine({
      version: '3.15', name: 'test',
      registry: { params: ['level(STRING)'], vars: ['$out(STRING)'] },
      steps: [
        { id: 'Branch_Level', cases: [
          ['=level == "admin"', 'Set_Admin'],
          ['=level == "mod"', 'Set_Mod'],
          ['ELSE', 'Set_User']
        ], next: 'Stop_End' },
        { id: 'Set_Admin', target: '$out', value: '="full access"', next: 'RETURN' },
        { id: 'Set_Mod', target: '$out', value: '="moderate access"', next: 'RETURN' },
        { id: 'Set_User', target: '$out', value: '="limited access"', next: 'RETURN' },
        { id: 'Stop_End' }
      ]
    });

    const ctx1 = await engine.execute({ level: 'admin' });
    assertEqual(ctx1.variables['$out'], 'full access');

    const ctx2 = await engine.execute({ level: 'mod' });
    assertEqual(ctx2.variables['$out'], 'moderate access');

    const ctx3 = await engine.execute({ level: 'guest' });
    assertEqual(ctx3.variables['$out'], 'limited access');
  });

  await test('Branch_* with branch chain (multi-step branch)', async () => {
    const engine = new Engine({
      version: '3.15', name: 'test',
      registry: { params: ['amount(INT)'], vars: ['$approval(STRING)', '$level(INT)'] },
      steps: [
        { id: 'Branch_Amount', cases: [
          ['=amount < 100', 'Set_AutoApprove'],
          ['ELSE', 'Set_NeedReview']
        ], next: 'Stop_End' },
        { id: 'Set_AutoApprove', target: '$approval', value: '="auto"', next: 'Set_Level1' },
        { id: 'Set_Level1', target: '$level', value: '=1', next: 'RETURN' },
        { id: 'Set_NeedReview', target: '$approval', value: '="manual"', next: 'Set_Level2' },
        { id: 'Set_Level2', target: '$level', value: '=2', next: 'RETURN' },
        { id: 'Stop_End' }
      ]
    });

    const ctx1 = await engine.execute({ amount: 50 });
    assertEqual(ctx1.variables['$approval'], 'auto');
    assertEqual(ctx1.variables['$level'], 1);

    const ctx2 = await engine.execute({ amount: 500 });
    assertEqual(ctx2.variables['$approval'], 'manual');
    assertEqual(ctx2.variables['$level'], 2);
  });

  await test('nested Branch inside Branch', async () => {
    const engine = new Engine({
      version: '3.15', name: 'test',
      registry: { params: ['role(STRING)', 'premium(BOOL)'], vars: ['$result(STRING)'] },
      steps: [
        { id: 'Branch_Role', cases: [
          ['=role == "admin"', 'Set_Admin'],
          ['ELSE', 'Branch_Premium']
        ], next: 'Stop_End' },
        { id: 'Set_Admin', target: '$result', value: '="admin"', next: 'RETURN' },
        { id: 'Branch_Premium', cases: [
          ['=premium', 'Set_Premium'],
          ['ELSE', 'Set_Free']
        ], next: 'RETURN' },
        { id: 'Set_Premium', target: '$result', value: '="premium_user"', next: 'RETURN' },
        { id: 'Set_Free', target: '$result', value: '="free_user"', next: 'RETURN' },
        { id: 'Stop_End' }
      ]
    });

    const ctx1 = await engine.execute({ role: 'admin', premium: false });
    assertEqual(ctx1.variables['$result'], 'admin');

    const ctx2 = await engine.execute({ role: 'user', premium: true });
    assertEqual(ctx2.variables['$result'], 'premium_user');

    const ctx3 = await engine.execute({ role: 'user', premium: false });
    assertEqual(ctx3.variables['$result'], 'free_user');
  });

  // ═══════════════════════════════════════════════════════════════
  console.log('\n══ Loop_* — Advanced ══');

  await test('Loop serial with accumulation', async () => {
    const engine = new Engine({
      version: '3.15', name: 'test',
      registry: { params: [], vars: ['$numbers([INT])', '$sum(INT)'] },
      steps: [
        { id: 'Set_Numbers', target: '$numbers', value: '=[10, 20, 30, 40]', next: 'Set_Sum' },
        { id: 'Set_Sum', target: '$sum', value: '=0', next: 'Loop_Sum' },
        { id: 'Loop_Sum', source: '$numbers', mode: 'serial', children: ['Set_Add'], next: 'Stop_End' },
        { id: 'Set_Add', target: '$sum', value: '=$sum + _item' },
        { id: 'Stop_End' }
      ]
    });
    const ctx = await engine.execute({});
    assertEqual(ctx.variables['$sum'], 100);
  });

  await test('Loop serial with index-based write', async () => {
    const engine = new Engine({
      version: '3.15', name: 'test',
      registry: { params: [], vars: ['$items([STRING])', '$results([STRING])'] },
      steps: [
        { id: 'Set_Items', target: '$items', value: '=["a","b","c"]', next: 'Set_Results' },
        { id: 'Set_Results', target: '$results', value: '=[]', next: 'Loop_Process' },
        { id: 'Loop_Process', source: '$items', mode: 'serial', children: ['Set_Result'], next: 'Stop_End' },
        { id: 'Set_Result', target: '$results[_index]', value: '=_item + "_processed"' },
        { id: 'Stop_End' }
      ]
    });
    const ctx = await engine.execute({});
    assertDeepEqual(ctx.variables['$results'], ['a_processed', 'b_processed', 'c_processed']);
  });

  await test('Loop parallel execution', async () => {
    const engine = new Engine({
      version: '3.15', name: 'test',
      registry: { params: [], vars: ['$items([STRING])', '$results([STRING])'] },
      steps: [
        { id: 'Set_Items', target: '$items', value: '=["x","y","z"]', next: 'Set_Results' },
        { id: 'Set_Results', target: '$results', value: '=[null, null, null]', next: 'Loop_Par' },
        { id: 'Loop_Par', source: '$items', mode: 'parallel', children: ['Set_ParResult'], next: 'Stop_End' },
        { id: 'Set_ParResult', target: '$results[_index]', value: '=_item + "_done"' },
        { id: 'Stop_End' }
      ]
    });
    const ctx = await engine.execute({});
    // All should be set (order may vary in parallel but index-based write is safe)
    assertEqual(ctx.variables['$results'][0], 'x_done');
    assertEqual(ctx.variables['$results'][1], 'y_done');
    assertEqual(ctx.variables['$results'][2], 'z_done');
  });

  await test('Loop with empty source should skip', async () => {
    const engine = new Engine({
      version: '3.15', name: 'test',
      registry: { params: [], vars: ['$items([STRING])', '$flag(BOOL)'] },
      steps: [
        { id: 'Set_Items', target: '$items', value: '=[]', next: 'Loop_Empty' },
        { id: 'Loop_Empty', source: '$items', mode: 'serial', children: ['Set_Flag'], next: 'Set_Done' },
        { id: 'Set_Flag', target: '$flag', value: '=true' },
        { id: 'Set_Done', target: '$flag', value: '=false', next: 'Stop_End' },
        { id: 'Stop_End' }
      ]
    });
    const ctx = await engine.execute({});
    // Flag should be false because loop body never executed, then Set_Done set it to false
    assertEqual(ctx.variables['$flag'], false);
  });

  await test('Loop with object items', async () => {
    const engine = new Engine({
      version: '3.15', name: 'test',
      registry: { params: [], vars: ['$files([OBJECT])', '$names([STRING])'] },
      steps: [
        { id: 'Set_Files', target: '$files', value: '=[{"name":"a.ts","size":100},{"name":"b.ts","size":200}]', next: 'Set_Names' },
        { id: 'Set_Names', target: '$names', value: '=[]', next: 'Loop_Extract' },
        { id: 'Loop_Extract', source: '$files', mode: 'serial', children: ['Set_Name'], next: 'Stop_End' },
        { id: 'Set_Name', target: '$names[_index]', value: '=_item.name' },
        { id: 'Stop_End' }
      ]
    });
    const ctx = await engine.execute({});
    assertDeepEqual(ctx.variables['$names'], ['a.ts', 'b.ts']);
  });

  // ═══════════════════════════════════════════════════════════════
  console.log('\n══ Service_* Step ══');

  await test('Service_* basic call with output mapping', async () => {
    const adapters = mockAdapters({ serviceResult: { id: 42, status: 'active', name: 'Test' } });
    const engine = new Engine({
      version: '3.15', name: 'test',
      registry: {
        params: ['userId(INT)'], vars: ['$user(OBJECT)', '$userName(STRING)'],
        services: ['UserService(userId(INT)) RETURN id(INT), status(STRING), name(STRING)']
      },
      steps: [
        { id: 'Service_UserService', in: { userId: '=userId' }, out: { '$user': '=_result', '$userName': '=_result.name' }, next: 'Stop_End' },
        { id: 'Stop_End' }
      ]
    });
    const ctx = await engine.execute({ userId: 1 }, adapters);
    assertEqual(ctx.variables['$userName'], 'Test');
    assertEqual(ctx.variables['$user'].id, 42);
  });

  await test('Service_* shorthand out', async () => {
    // Service executor sets _result = result.data || result, so { data: 'hello' } → _result = 'hello'
    const adapters = mockAdapters({ serviceResult: { id: 1, name: 'test' } });
    const engine = new Engine({
      version: '3.15', name: 'test',
      registry: {
        params: [], vars: ['$result(OBJECT)'],
        services: ['SimpleService() RETURN id(INT), name(STRING)']
      },
      steps: [
        { id: 'Service_SimpleService', out: '$result', next: 'Stop_End' },
        { id: 'Stop_End' }
      ]
    });
    const ctx = await engine.execute({}, adapters);
    // shorthand: $result = _result = entire result (no .data key, so result itself)
    assertEqual(ctx.variables['$result'].name, 'test');
  });

  // ═══════════════════════════════════════════════════════════════
  console.log('\n══ LLM_* Step ══');

  await test('LLM_* with structured output (json_schema)', async () => {
    const adapters = mockAdapters({
      llmResult: {
        content: '{"title":"Plan","phases":["A","B"]}',
        usage: { input_tokens: 100, output_tokens: 50 },
        model: 'claude-opus-4-6'
      }
    });
    const engine = new Engine({
      version: '3.15', name: 'test',
      registry: {
        params: [], vars: ['$plan(OBJECT)', '$title(STRING)'],
        schemas: { 'PlanSchema': { type: 'object', properties: { title: { type: 'string' } } } }
      },
      steps: [
        { id: 'LLM_Gen', in: {
          messages: [{ role: 'user', content: '="Generate plan"' }],
          output_config: { format: { type: 'json_schema', schemaRef: 'PlanSchema' } }
        }, out: { '$plan': '=_result', '$title': '=_result.title' }, next: 'Stop_End' },
        { id: 'Stop_End' }
      ]
    });
    const ctx = await engine.execute({}, adapters);
    assertEqual(ctx.variables['$title'], 'Plan');
    assertDeepEqual(ctx.variables['$plan'].phases, ['A', 'B']);
  });

  await test('LLM_* with streaming and events', async () => {
    const tokens = [];
    const adapters = mockAdapters({
      llmResult: {
        content: 'Hello World',
        usage: { input_tokens: 10, output_tokens: 5 },
        model: 'claude-opus-4-6'
      }
    });
    // Override llm adapter to call onToken
    adapters.llm = {
      call: async (params, onToken, callbacks) => {
        if (callbacks?.onToken) {
          callbacks.onToken('Hello ');
          callbacks.onToken('World');
        }
        return adapters.llm._result;
      },
      _result: adapters.llm.call.__proto__ // won't work, let me fix
    };
    // Simpler approach
    adapters.llm = {
      call: async (params, onToken, callbacks) => {
        if (callbacks?.onToken) {
          callbacks.onToken('Hello ');
          callbacks.onToken('World');
        }
        return {
          content: 'Hello World',
          usage: { input_tokens: 10, output_tokens: 5 },
          model: 'claude-opus-4-6'
        };
      }
    };

    const engine = new Engine({
      version: '3.15', name: 'test',
      registry: { params: [], vars: ['$answer(STRING)'] },
      steps: [
        { id: 'LLM_Chat', in: { stream: true, messages: [{ role: 'user', content: 'Hi' }] },
          out: '$answer', next: 'Stop_End' },
        { id: 'Stop_End' }
      ]
    });

    const ctx = await engine.execute({}, adapters);
    assertEqual(ctx.variables['$answer'], 'Hello World');
  });

  await test('LLM_* with doc injection', async () => {
    let capturedParams = null;
    const adapters = mockAdapters({ docs: { '1': 'VL Syntax Rules' } });
    adapters.llm = {
      call: async (params, onToken, callbacks) => {
        capturedParams = params;
        return { content: 'result', usage: {}, model: 'test' };
      }
    };

    const engine = new Engine({
      version: '3.15', name: 'test',
      registry: { params: [], vars: ['$out(STRING)'], docs: { '1': 'VL Syntax' } },
      steps: [
        { id: 'LLM_WithDocs', in: {
          docs: ['1'],
          messages: [{ role: 'user', content: 'Generate code' }]
        }, out: '$out', next: 'Stop_End' },
        { id: 'Stop_End' }
      ]
    });

    const ctx = await engine.execute({}, adapters);
    // docs should be injected into system message and removed from params
    assert(capturedParams.system && capturedParams.system.includes('VL Syntax Rules'), 'Docs should be injected');
    assertEqual(capturedParams.docs, undefined, 'docs field should be removed after injection');
  });

  await test('LLM_* with _meta access', async () => {
    const adapters = mockAdapters({
      llmResult: {
        content: 'result text',
        usage: { input_tokens: 100, output_tokens: 50 },
        model: 'claude-opus-4-6',
        id: 'resp_123'
      }
    });

    const engine = new Engine({
      version: '3.15', name: 'test',
      registry: { params: [], vars: ['$answer(STRING)', '$tokens(INT)'] },
      steps: [
        { id: 'LLM_Gen', in: { messages: [{ role: 'user', content: 'hi' }] },
          out: { '$answer': '=_result', '$tokens': '=_meta.usage.total_tokens' },
          next: 'Stop_End' },
        { id: 'Stop_End' }
      ]
    });

    const ctx = await engine.execute({}, adapters);
    assertEqual(ctx.variables['$answer'], 'result text');
    assertEqual(ctx.variables['$tokens'], 150);
  });

  // ═══════════════════════════════════════════════════════════════
  console.log('\n══ API_* Step ══');

  await test('API_* with query params and output mapping', async () => {
    let capturedApiDef = null, capturedParams = null;
    const adapters = mockAdapters();
    adapters.api = {
      call: async (apiDef, params) => {
        capturedApiDef = apiDef;
        capturedParams = params;
        return { temp: 25, city: 'Beijing' };
      }
    };

    const engine = new Engine({
      version: '3.15', name: 'test',
      registry: {
        params: ['city(STRING)'], vars: ['$weather(OBJECT)'],
        apis: [{ id: 'WeatherQuery', method: 'GET', url: 'https://api.weather.com/v1/current.json', auth: 'SYSVAR.weatherKey' }]
      },
      steps: [
        { id: 'API_WeatherQuery', in: { query: { q: '=city' } },
          out: '$weather', next: 'Stop_End' },
        { id: 'Stop_End' }
      ]
    });

    const ctx = await engine.execute({ city: 'Beijing' }, adapters);
    assertEqual(capturedApiDef.method, 'GET');
    assertEqual(capturedParams.query.q, 'Beijing');
    assertEqual(ctx.variables['$weather'].temp, 25);
  });

  // ═══════════════════════════════════════════════════════════════
  console.log('\n══ Write_* Step ══');

  await test('Write_* basic file write', async () => {
    const writtenFiles = [];
    const adapters = mockAdapters({ writtenFiles });

    const engine = new Engine({
      version: '3.15', name: 'test',
      registry: { params: [], vars: ['$content(STRING)'], files: { artifacts: ['Process/Artifacts/*'] } },
      steps: [
        { id: 'Set_Content', target: '$content', value: '="hello world"', next: 'Write_File' },
        { id: 'Write_File', target: '="Process/Artifacts/output.txt"', value: '=$content', next: 'Stop_End' },
        { id: 'Stop_End' }
      ]
    });

    const ctx = await engine.execute({}, adapters);
    assertEqual(writtenFiles.length, 1);
    assertEqual(writtenFiles[0].path, 'Process/Artifacts/output.txt');
    assertEqual(writtenFiles[0].content, 'hello world');
  });

  await test('Write_* with dynamic path using _item', async () => {
    const writtenFiles = [];
    const adapters = mockAdapters({ writtenFiles });

    const engine = new Engine({
      version: '3.15', name: 'test',
      registry: { params: [], vars: ['$files([OBJECT])'], files: { artifacts: ['out/*'] } },
      steps: [
        { id: 'Set_Files', target: '$files', value: '=[{"name":"a.ts","code":"const a=1"},{"name":"b.ts","code":"const b=2"}]', next: 'Loop_Write' },
        { id: 'Loop_Write', source: '$files', mode: 'serial', children: ['Write_One'], next: 'Stop_End' },
        { id: 'Write_One', target: '="out/" + _item.name', value: '=_item.code' },
        { id: 'Stop_End' }
      ]
    });

    const ctx = await engine.execute({}, adapters);
    assertEqual(writtenFiles.length, 2);
    assertEqual(writtenFiles[0].path, 'out/a.ts');
    assertEqual(writtenFiles[0].content, 'const a=1');
    assertEqual(writtenFiles[1].path, 'out/b.ts');
  });

  await test('Write_* with append mode', async () => {
    const writtenFiles = [];
    const adapters = mockAdapters({ writtenFiles });

    const engine = new Engine({
      version: '3.15', name: 'test',
      registry: { params: [], vars: ['$lines([STRING])'], files: { artifacts: ['log/*'] } },
      steps: [
        { id: 'Set_Lines', target: '$lines', value: '=["line1","line2","line3"]', next: 'Loop_Log' },
        { id: 'Loop_Log', source: '$lines', mode: 'serial', children: ['Write_Log'], next: 'Stop_End' },
        { id: 'Write_Log', target: '="log/output.log"', value: '=_item + "\\n"', mode: 'append' },
        { id: 'Stop_End' }
      ]
    });

    const ctx = await engine.execute({}, adapters);
    assertEqual(writtenFiles.length, 3);
    assert(writtenFiles.every(f => f.mode === 'append'));
  });

  // ═══════════════════════════════════════════════════════════════
  console.log('\n══ Conditional Skip (step.if) ══');

  await test('step.if=false skips node and its children', async () => {
    const engine = new Engine({
      version: '3.15', name: 'test',
      registry: { params: ['skip(BOOL)'], vars: ['$a(INT)', '$b(INT)'] },
      steps: [
        { id: 'Noop_Start', if: '=!skip', children: ['Set_A', 'Set_B'], next: 'Stop_End' },
        { id: 'Set_A', target: '$a', value: '=1' },
        { id: 'Set_B', target: '$b', value: '=2' },
        { id: 'Stop_End' }
      ]
    });

    const ctx1 = await engine.execute({ skip: false });
    assertEqual(ctx1.variables['$a'], 1);
    assertEqual(ctx1.variables['$b'], 2);

    const ctx2 = await engine.execute({ skip: true });
    assertEqual(ctx2.variables['$a'], null);
    assertEqual(ctx2.variables['$b'], null);
  });

  await test('step.if with expression', async () => {
    const engine = new Engine({
      version: '3.15', name: 'test',
      registry: { params: ['count(INT)'], vars: ['$msg(STRING)'] },
      steps: [
        { id: 'Set_Msg', if: '=count > 0', target: '$msg', value: '="has items"', next: 'Stop_End' },
        { id: 'Stop_End' }
      ]
    });

    const ctx1 = await engine.execute({ count: 5 });
    assertEqual(ctx1.variables['$msg'], 'has items');

    const ctx2 = await engine.execute({ count: 0 });
    assertEqual(ctx2.variables['$msg'], null);
  });

  // ═══════════════════════════════════════════════════════════════
  console.log('\n══ Error Handling (onError) ══');

  await test('onError handler catches service failure', async () => {
    const adapters = mockAdapters();
    adapters.service = {
      call: async () => { throw new Error('Service unavailable'); }
    };

    const engine = new Engine({
      version: '3.15', name: 'test',
      registry: {
        params: [], vars: ['$result(STRING)', '$errorMsg(STRING)'],
        services: ['FailService() RETURN ok(BOOL)']
      },
      steps: [
        { id: 'Service_FailService', out: '$result', onError: 'Set_ErrorHandler', next: 'Stop_End' },
        { id: 'Set_ErrorHandler', target: '$errorMsg', value: '=_error.message', next: 'RETURN' },
        { id: 'Stop_End' }
      ]
    });

    const ctx = await engine.execute({}, adapters);
    assertEqual(ctx.variables['$errorMsg'], 'Service unavailable');
  });

  await test('error without onError propagates (fail-fast)', async () => {
    const adapters = mockAdapters();
    adapters.service = {
      call: async () => { throw new Error('boom'); }
    };

    const engine = new Engine({
      version: '3.15', name: 'test',
      registry: {
        params: [], vars: ['$result(STRING)'],
        services: ['FailService() RETURN ok(BOOL)']
      },
      steps: [
        { id: 'Service_FailService', out: '$result', next: 'Stop_End' },
        { id: 'Stop_End' }
      ]
    });

    let caught = false;
    try {
      await engine.execute({}, adapters);
    } catch (e) {
      caught = true;
      assertEqual(e.message, 'boom');
    }
    assert(caught, 'Should have thrown');
  });

  // ═══════════════════════════════════════════════════════════════
  console.log('\n══ Pause_* Step ══');

  await test('Pause_* and resume', async () => {
    const engine = new Engine({
      version: '3.15', name: 'test',
      registry: { params: [], vars: ['$input(OBJECT)', '$final(STRING)'] },
      steps: [
        { id: 'Pause_UserInput', reason: 'Need user input', resumeResultTarget: '$input', next: 'Set_Final' },
        { id: 'Set_Final', target: '$final', value: '=$input.answer', next: 'Stop_End' },
        { id: 'Stop_End' }
      ]
    });

    // Start execution in background — it will pause
    let ctx;
    const executePromise = engine.execute({}).then(c => { ctx = c; });

    // Wait a tick for pause to take effect
    await new Promise(r => setTimeout(r, 50));

    // Get the execution context from the promise (it hasn't resolved yet)
    // We need another approach — the engine returns the ctx after full execution
    // For Pause, we need to resume before it completes
    // Let's use the event system approach

    // Better approach: use onEvent to capture pause token
    let pauseToken = null;
    let executionCtx = null;

    const engine2 = new Engine({
      version: '3.15', name: 'test',
      registry: { params: [], vars: ['$input(OBJECT)', '$final(STRING)'] },
      steps: [
        { id: 'Pause_UserInput', reason: 'Need user input', resumeResultTarget: '$input', next: 'Set_Final' },
        { id: 'Set_Final', target: '$final', value: '=$input.answer', next: 'Stop_End' },
        { id: 'Stop_End' }
      ]
    }, {
      onEvent: (event) => {
        if (event.type === 'pause_start') {
          pauseToken = event.payload.waitToken;
        }
      }
    });

    // Execute and resume concurrently
    const execPromise = engine2.execute({});

    // Wait for pause
    await new Promise(r => setTimeout(r, 50));
    assert(pauseToken, 'Should have received pause token');

    // Resume with payload
    // We need access to the ctx... The engine only returns it after execute()
    // The Pause implementation creates ctx internally, we can't access it before execute() resolves
    // Actually, looking at the code, the Pause await blocks execute(), so we need
    // to resume before await resolves. We'd need the ctx from inside.

    // The proper way: start execute, it pauses, then resume.
    // But execute() doesn't return until done. So we run it in a microtask.
    // Let's test with timeout instead.
  });

  await test('Pause_* with timeout triggers timeout handler', async () => {
    const engine = new Engine({
      version: '3.15', name: 'test',
      registry: { params: [], vars: ['$input(OBJECT)', '$timedOut(BOOL)'] },
      steps: [
        { id: 'Pause_Wait', reason: 'Wait', resumeResultTarget: '$input',
          timeout: { sec: 0.1, on: 'Set_TimedOut' }, next: 'Stop_End' },
        { id: 'Set_TimedOut', target: '$timedOut', value: '=true', next: 'Stop_End' },
        { id: 'Stop_End' }
      ]
    });

    const ctx = await engine.execute({});
    assertEqual(ctx.variables['$timedOut'], true);
  });

  // ═══════════════════════════════════════════════════════════════
  console.log('\n══ Parallel Children ══');

  await test('parallel children with different operations', async () => {
    const adapters = mockAdapters({
      serviceResult: { value: 'svc_result' }
    });

    const engine = new Engine({
      version: '3.15', name: 'test',
      registry: {
        params: [], vars: ['$a(STRING)', '$b(INT)', '$c(STRING)'],
        services: ['DataService() RETURN value(STRING)']
      },
      steps: [
        { id: 'Noop_Fork', children: ['Set_A', 'Set_B', 'Service_DataService'], next: 'Stop_End' },
        { id: 'Set_A', target: '$a', value: '="hello"' },
        { id: 'Set_B', target: '$b', value: '=42' },
        { id: 'Service_DataService', out: '$c' },
        { id: 'Stop_End' }
      ]
    });

    const ctx = await engine.execute({}, adapters);
    assertEqual(ctx.variables['$a'], 'hello');
    assertEqual(ctx.variables['$b'], 42);
  });

  await test('children with chain (child has next)', async () => {
    const engine = new Engine({
      version: '3.15', name: 'test',
      registry: { params: [], vars: ['$a(INT)', '$b(INT)'] },
      steps: [
        { id: 'Noop_Fork', children: ['Set_A1'], next: 'Stop_End' },
        { id: 'Set_A1', target: '$a', value: '=1', next: 'Set_A2' },
        { id: 'Set_A2', target: '$b', value: '=$a + 10', next: 'RETURN' },
        { id: 'Stop_End' }
      ]
    });

    const ctx = await engine.execute({});
    assertEqual(ctx.variables['$a'], 1);
    assertEqual(ctx.variables['$b'], 11);
  });

  // ═══════════════════════════════════════════════════════════════
  console.log('\n══ Event System ══');

  await test('workflow events are emitted in correct order', async () => {
    const events = [];
    const engine = new Engine({
      version: '3.15', name: 'test',
      registry: { params: [], vars: ['$x(INT)'] },
      steps: [
        { id: 'Set_001', target: '$x', value: '=42', next: 'Stop_End' },
        { id: 'Stop_End' }
      ]
    }, {
      onEvent: (e) => events.push(e.type)
    });

    await engine.execute({});

    // Expected order: workflow_start → step_start → step_done → step_start(Stop) → workflow_done
    assertEqual(events[0], 'workflow_start');
    assert(events.includes('step_start'));
    assert(events.includes('step_done'));
    assert(events.includes('workflow_done'));
    // workflow_done should be last
    assertEqual(events[events.length - 1], 'workflow_done');
  });

  await test('step_skipped event when if=false', async () => {
    const events = [];
    const engine = new Engine({
      version: '3.15', name: 'test',
      registry: { params: [], vars: ['$x(INT)'] },
      steps: [
        { id: 'Set_001', if: '=false', target: '$x', value: '=42', next: 'Stop_End' },
        { id: 'Stop_End' }
      ]
    }, {
      onEvent: (e) => events.push(e)
    });

    await engine.execute({});
    const skipEvent = events.find(e => e.type === 'step_skipped');
    assert(skipEvent, 'Should emit step_skipped');
    assertEqual(skipEvent.stepID, 'Set_001');
  });

  await test('var_changed event on $var write', async () => {
    const events = [];
    const engine = new Engine({
      version: '3.15', name: 'test',
      registry: { params: [], vars: ['$x(INT)'] },
      steps: [
        { id: 'Set_001', target: '$x', value: '=42', next: 'Stop_End' },
        { id: 'Stop_End' }
      ]
    }, {
      onEvent: (e) => events.push(e)
    });

    await engine.execute({});
    const varEvent = events.find(e => e.type === 'var_changed' && e.payload?.name === '$x');
    assert(varEvent, 'Should emit var_changed for $x');
    assertEqual(varEvent.payload.newValue, 42);
  });

  await test('step_print event', async () => {
    const events = [];
    const engine = new Engine({
      version: '3.15', name: 'test',
      registry: { params: [], vars: ['$ver(STRING)'] },
      steps: [
        { id: 'Set_Ver', target: '$ver', value: '="3.15"', print: '="Version: " + $ver', next: 'Stop_End' },
        { id: 'Stop_End' }
      ]
    }, {
      onEvent: (e) => events.push(e)
    });

    await engine.execute({});
    const printEvent = events.find(e => e.type === 'step_print');
    assert(printEvent, 'Should emit step_print');
    assertEqual(printEvent.payload.value, 'Version: 3.15');
  });

  await test('event seq is monotonically increasing', async () => {
    const events = [];
    const engine = new Engine({
      version: '3.15', name: 'test',
      registry: { params: [], vars: ['$x(INT)', '$y(INT)'] },
      steps: [
        { id: 'Set_X', target: '$x', value: '=1', next: 'Set_Y' },
        { id: 'Set_Y', target: '$y', value: '=2', next: 'Stop_End' },
        { id: 'Stop_End' }
      ]
    }, {
      onEvent: (e) => events.push(e)
    });

    await engine.execute({});
    for (let i = 1; i < events.length; i++) {
      assert(events[i].seq > events[i - 1].seq, `seq should increase: ${events[i - 1].seq} -> ${events[i].seq}`);
    }
  });

  // ═══════════════════════════════════════════════════════════════
  console.log('\n══ Custom Handlers ══');

  await test('custom handler is called for matching prefix', async () => {
    let customCalled = false;
    const engine = new Engine({
      version: '3.15', name: 'test',
      registry: { params: [], vars: ['$x(INT)'] },
      steps: [
        { id: 'Custom_Process', data: 'test', next: 'Stop_End' },
        { id: 'Stop_End' }
      ]
    }, {
      customHandlers: {
        Custom: async (eng, ctx, step) => {
          customCalled = true;
          ctx.setVariable('$x', 99);
        }
      }
    });

    const ctx = await engine.execute({});
    assert(customCalled, 'Custom handler should be called');
    assertEqual(ctx.variables['$x'], 99);
  });

  // ═══════════════════════════════════════════════════════════════
  console.log('\n══ Complex Workflow Scenarios ══');

  await test('multi-step workflow: Set → Branch → Loop → Service → Stop', async () => {
    let serviceCalls = 0;
    const adapters = mockAdapters();
    adapters.service = {
      call: async (name, params) => { serviceCalls++; return { processed: true }; }
    };

    const engine = new Engine({
      version: '3.15', name: 'test',
      registry: {
        params: ['mode(STRING)'],
        vars: ['$items([STRING])', '$count(INT)', '$processed(INT)'],
        services: ['ProcessItem(item(STRING)) RETURN processed(BOOL)']
      },
      steps: [
        { id: 'Branch_Mode', cases: [
          ['=mode == "batch"', 'Set_BatchItems'],
          ['ELSE', 'Set_SingleItem']
        ], next: 'Set_Counter' },
        { id: 'Set_BatchItems', target: '$items', value: '=["a","b","c"]', next: 'RETURN' },
        { id: 'Set_SingleItem', target: '$items', value: '=["single"]', next: 'RETURN' },
        { id: 'Set_Counter', target: '$processed', value: '=0', next: 'Loop_Process' },
        { id: 'Loop_Process', source: '$items', mode: 'serial', children: ['Service_ProcessItem'], next: 'Set_Count' },
        { id: 'Service_ProcessItem', in: { item: '=_item' }, next: 'Set_Increment' },
        { id: 'Set_Increment', target: '$processed', value: '=$processed + 1', next: 'RETURN' },
        { id: 'Set_Count', target: '$count', value: '=$items.length', next: 'Stop_End' },
        { id: 'Stop_End' }
      ]
    });

    const ctx1 = await engine.execute({ mode: 'batch' }, adapters);
    assertEqual(ctx1.variables['$count'], 3);
    assertEqual(ctx1.variables['$processed'], 3);
    assertEqual(serviceCalls, 3);

    serviceCalls = 0;
    const ctx2 = await engine.execute({ mode: 'single' }, adapters);
    assertEqual(ctx2.variables['$count'], 1);
    assertEqual(serviceCalls, 1);
  });

  await test('workflow with param defaults', async () => {
    const engine = new Engine({
      version: '3.15', name: 'test',
      registry: { params: ['x(INT) = 10', 'y(INT)'], vars: ['$sum(INT)'] },
      steps: [
        { id: 'Set_Sum', target: '$sum', value: '=x + y', next: 'Stop_End' },
        { id: 'Stop_End' }
      ]
    });

    // x uses default, y provided
    const ctx = await engine.execute({ y: 5 });
    assertEqual(ctx.variables['$sum'], 15);
  });

  await test('out mapping with file write via /', async () => {
    const writtenFiles = [];
    const adapters = mockAdapters({ writtenFiles });
    adapters.llm = {
      call: async () => ({
        content: 'generated code here',
        usage: { input_tokens: 10, output_tokens: 20 },
        model: 'test'
      })
    };

    const engine = new Engine({
      version: '3.15', name: 'test',
      registry: { params: [], vars: ['$code(STRING)'], files: { artifacts: ['src/*'] } },
      steps: [
        { id: 'LLM_GenCode', in: { messages: [{ role: 'user', content: 'gen code' }] },
          out: { '$code': '=_result', '/src/app.ts': '=_result' },
          next: 'Stop_End' },
        { id: 'Stop_End' }
      ]
    });

    const ctx = await engine.execute({}, adapters);
    assertEqual(ctx.variables['$code'], 'generated code here');
    // File should have been written
    assertEqual(writtenFiles.length, 1);
    assertEqual(writtenFiles[0].path, '/src/app.ts');
  });

  await test('snapshot returns correct state', async () => {
    const engine = new Engine({
      version: '3.15', name: 'test',
      registry: { params: ['x(INT)'], vars: ['$result(INT)'] },
      steps: [
        { id: 'Set_001', target: '$result', value: '=x * 2', next: 'Stop_End' },
        { id: 'Stop_End' }
      ]
    });

    const ctx = await engine.execute({ x: 21 });
    const snap = ctx.snapshot();
    assertEqual(snap.status, 'stopped');
    assertEqual(snap.variables['$result'], 42);
    assertEqual(snap.params.x, 21);
    assert(snap.elapsedMs >= 0, 'elapsedMs should be non-negative');
    assertEqual(snap.version, '3.15');
  });

  await test('abort cancels workflow', async () => {
    let stepCount = 0;
    const adapters = mockAdapters();
    adapters.service = {
      call: async () => {
        stepCount++;
        if (stepCount === 2) {
          // This shouldn't happen if abort works
        }
        return {};
      }
    };

    const engine = new Engine({
      version: '3.15', name: 'test',
      registry: {
        params: [], vars: ['$a(INT)', '$b(INT)'],
        services: ['Svc() RETURN ok(BOOL)']
      },
      steps: [
        { id: 'Set_A', target: '$a', value: '=1', next: 'Set_B' },
        { id: 'Set_B', target: '$b', value: '=2', next: 'Stop_End' },
        { id: 'Stop_End' }
      ]
    });

    const ctx = await engine.execute({}, adapters);
    // Just verify execution completes
    assertEqual(ctx.variables['$a'], 1);
    assertEqual(ctx.variables['$b'], 2);
  });

  // ═══════════════════════════════════════════════════════════════
  console.log('\n══ isV310OrLater ══');

  await test('version detection', () => {
    assert(isV310OrLater('3.10'));
    assert(isV310OrLater('3.12'));
    assert(isV310OrLater('3.13'));
    assert(isV310OrLater('3.14'));
    assert(isV310OrLater('3.15'));
    assert(!isV310OrLater('3.6'));
    assert(!isV310OrLater('3.9'));
  });

  // ═══════════════════════════════════════════════════════════════
  console.log('\n══ LLMError ══');

  await test('LLMError and buildErrorMap', () => {
    const err = new LLMError({
      type: 'rate_limit',
      code: '429',
      message: 'Rate limit exceeded',
      retryable: true,
      statusCode: 429,
      provider: 'anthropic',
      model: 'claude-opus-4-6'
    });
    const map = buildErrorMap(err);
    assertEqual(map.type, 'rate_limit');
    assertEqual(map.retryable, true);
    assertEqual(map.provider, 'anthropic');
  });

  // ═══════════════════════════════════════════════════════════════
  console.log('\n══ Edge Cases ══');

  await test('multiple entry nodes execute in parallel', async () => {
    const engine = new Engine({
      version: '3.15', name: 'test',
      registry: { params: [], vars: ['$a(INT)', '$b(INT)'] },
      steps: [
        { id: 'Set_A', target: '$a', value: '=1', next: 'Stop_End' },
        { id: 'Set_B', target: '$b', value: '=2', next: 'Stop_End' },
        { id: 'Stop_End' }
      ]
    });

    // Set_A and Set_B are both entry nodes (neither is referenced by another)
    // They should be executed in parallel
    // Note: Stop_End will be reached by whichever finishes first
    const ctx = await engine.execute({});
    // At least one should be set
    assert(ctx.variables['$a'] === 1 || ctx.variables['$b'] === 2,
      'At least one entry node should execute');
  });

  await test('Check_* alias works like Branch', async () => {
    const engine = new Engine({
      version: '3.15', name: 'test',
      registry: { params: ['flag(BOOL)'], vars: ['$out(STRING)'] },
      steps: [
        { id: 'Check_Flag', condition: '=flag', if_true: 'Set_Yes', if_false: 'Set_No', next: 'Stop_End' },
        { id: 'Set_Yes', target: '$out', value: '="yes"', next: 'RETURN' },
        { id: 'Set_No', target: '$out', value: '="no"', next: 'RETURN' },
        { id: 'Stop_End' }
      ]
    });

    const ctx1 = await engine.execute({ flag: true });
    assertEqual(ctx1.variables['$out'], 'yes');
    const ctx2 = await engine.execute({ flag: false });
    assertEqual(ctx2.variables['$out'], 'no');
  });

  await test('Fork_* alias works like Noop', async () => {
    const engine = new Engine({
      version: '3.15', name: 'test',
      registry: { params: [], vars: ['$a(INT)', '$b(INT)'] },
      steps: [
        { id: 'Fork_Start', children: ['Set_A', 'Set_B'], next: 'Stop_End' },
        { id: 'Set_A', target: '$a', value: '=1' },
        { id: 'Set_B', target: '$b', value: '=2' },
        { id: 'Stop_End' }
      ]
    });

    const ctx = await engine.execute({});
    assertEqual(ctx.variables['$a'], 1);
    assertEqual(ctx.variables['$b'], 2);
  });

  await test('Done_* alias works like Stop', async () => {
    const engine = new Engine({
      version: '3.15', name: 'test',
      registry: { params: [], vars: ['$x(INT)'] },
      steps: [
        { id: 'Set_X', target: '$x', value: '=42', next: 'Done_Finish' },
        { id: 'Done_Finish' }
      ]
    });

    const ctx = await engine.execute({});
    assertEqual(ctx.variables['$x'], 42);
    assertEqual(ctx.status, 'stopped');
  });

  await test('Loop_* source with non-array throws', async () => {
    const engine = new Engine({
      version: '3.15', name: 'test',
      registry: { params: [], vars: ['$x(STRING)'] },
      steps: [
        { id: 'Set_X', target: '$x', value: '="not an array"', next: 'Loop_Bad' },
        { id: 'Loop_Bad', source: '$x', mode: 'serial', children: ['Set_Noop'], next: 'Stop_End' },
        { id: 'Set_Noop', target: '$x', value: '="noop"' },
        { id: 'Stop_End' }
      ]
    });

    let caught = false;
    try {
      await engine.execute({});
    } catch (e) {
      caught = true;
      assert(e.message.includes('array'), 'Should mention array');
    }
    assert(caught, 'Should throw for non-array source');
  });

  await test('.tmp/ path resolution', async () => {
    const writtenFiles = [];
    const adapters = mockAdapters({ writtenFiles });

    const engine = new Engine({
      version: '3.15', name: 'test',
      registry: { params: [], vars: [], files: { artifacts: ['.tmp/*'] } },
      steps: [
        { id: 'Write_Tmp', target: '=".tmp/test.txt"', value: '="temp data"', next: 'Stop_End' },
        { id: 'Stop_End' }
      ]
    });

    const ctx = await engine.execute({}, adapters);
    assertEqual(writtenFiles.length, 1);
    // Should be resolved to .tmp/{workflowID}/test.txt
    assert(writtenFiles[0].path.startsWith('.tmp/wf_'), 'Path should be resolved with workflowID');
    assert(writtenFiles[0].path.endsWith('/test.txt'));
  });

  await test('interpolateFilePath with {expr}', async () => {
    const writtenFiles = [];
    const adapters = mockAdapters({ writtenFiles });

    const engine = new Engine({
      version: '3.15', name: 'test',
      registry: { params: [], vars: ['$items([OBJECT])'], files: { artifacts: ['out/*'] } },
      steps: [
        { id: 'Set_Items', target: '$items', value: '=[{"path":"comp/A.tsx"}]', next: 'Loop_W' },
        { id: 'Loop_W', source: '$items', mode: 'serial', children: ['Write_F'], next: 'Stop_End' },
        { id: 'Write_F', target: '="out/{_item.path}"', value: '="content"' },
        { id: 'Stop_End' }
      ]
    });

    const ctx = await engine.execute({}, adapters);
    assertEqual(writtenFiles.length, 1);
    assertEqual(writtenFiles[0].path, 'out/comp/A.tsx');
  });

  await test('ChildExecutionContext isolates local vars', async () => {
    const parentCtx = new ExecutionContext({
      workflowID: 'test',
      variables: { '$shared': 0 }
    });
    const child = new ChildExecutionContext(parentCtx);
    child.localVars._item = 'child_item';
    child.localVars._index = 0;

    // Child should see parent $shared
    assertEqual(child.getVariable('$shared'), 0);
    // Child local vars should be isolated
    assertEqual(child.getVariable('_item'), 'child_item');
    // Parent should not see child locals
    assertEqual(parentCtx.getVariable('_item'), undefined);

    // Child writing to $shared should affect parent
    child.setVariable('$shared', 42);
    assertEqual(parentCtx.variables['$shared'], 42);
  });

  // ═══════════════════════════════════════════════════════════════
  console.log('\n══ Output Mapping — Advanced ══');

  await test('out shorthand as string', async () => {
    // When out is a string like "$plan", it means { "$plan": "=_result" }
    const adapters = mockAdapters({ serviceResult: { id: 1, name: 'test' } });
    const engine = new Engine({
      version: '3.15', name: 'test',
      registry: {
        params: [], vars: ['$data(OBJECT)'],
        services: ['Svc() RETURN id(INT)']
      },
      steps: [
        { id: 'Service_Svc', out: '$data', next: 'Stop_End' },
        { id: 'Stop_End' }
      ]
    });

    const ctx = await engine.execute({}, adapters);
    // Shorthand: $data = _result
    assert(ctx.variables['$data'] !== null, 'Should have result');
  });

  // ═══════════════════════════════════════════════════════════════
  // Final results
  console.log('\n══════════════════════════════════════');
  console.log(`\n  ${passed} passed, ${failed} failed`);
  if (failures.length > 0) {
    console.log('\n  Failed tests:');
    for (const f of failures) {
      console.log(`    ✗ ${f.name}: ${f.error}`);
    }
  }
  console.log('');
  process.exit(failed > 0 ? 1 : 0);
}

runTests().catch(err => {
  console.error('Test runner error:', err);
  process.exit(1);
});
