import { describe, it, expect, beforeEach } from 'vitest';
import { LadderEngine } from './engine';
import type { Program, Rung } from './types';
describe('Edge-Triggered Logic (Timers & Counters)', () => {
let engine: LadderEngine;
beforeEach(() => {
engine = new LadderEngine();
});
describe('TON (Timer On Delay)', () => {
it('should delay output until preset is reached', () => {
const rung: Rung = {
id: 'r1',
enabled: true,
series: [
{ type: 'contact', contactType: 'NO', address: 'I:0.0' },
{ type: 'timer', timerType: 'TON', instanceId: 'T1', preset: 300 , accumulated: 0},
{ type: 'coil', coilType: 'OUTPUT', address: 'Q:0.0' },
],
};
engine.loadProgram({ name: 'test', rungs: [rung], cycleTime: 100 });
// Scan 1: Input true, timer starts
engine.setInput('I:0.0', true);
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(false);
expect(engine.getState().timers.get('T1')?.accumulated).toBe(100);
// Scan 2: Timer at 200
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(false);
expect(engine.getState().timers.get('T1')?.accumulated).toBe(200);
// Scan 3: Timer at 300 >= preset
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(true);
// Scan 4: Input false, Timer resets
engine.setInput('I:0.0', false);
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(false);
expect(engine.getState().timers.get('T1')?.accumulated).toBe(0);
});
});
describe('TOF (Timer Off Delay)', () => {
it('should keep output ON while input is true, then maintain ON during off-delay, then OFF', () => {
const rung: Rung = {
id: 'r1',
enabled: true,
series: [
{ type: 'contact', contactType: 'NO', address: 'I:0.0' },
{ type: 'timer', timerType: 'TOF', instanceId: 'T1', preset: 200 , accumulated: 0},
{ type: 'coil', coilType: 'OUTPUT', address: 'Q:0.0' },
],
};
engine.loadProgram({ name: 'test', rungs: [rung], cycleTime: 100 });
// Scan 1: Input true, Output ON immediately
engine.setInput('I:0.0', true);
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(true);
// Scan 2: Input goes false, timer starts — output stays ON (off-delay active)
engine.setInput('I:0.0', false);
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(true); // Studio 5000: still ON during off-delay
expect(engine.getState().timers.get('T1')?.accumulated).toBe(100);
// Scan 3: Timer at 200 = preset — off-delay complete, output OFF
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(false); // Timer done
expect(engine.getState().timers.get('T1')?.accumulated).toBe(200);
});
});
describe('TP (Timer Pulse)', () => {
it('should pulse output while input is true and pulse is active', () => {
const rung: Rung = {
id: 'r1',
enabled: true,
series: [
{ type: 'contact', contactType: 'NO', address: 'I:0.0' },
{ type: 'timer', timerType: 'TP', instanceId: 'T1', preset: 200 , accumulated: 0},
{ type: 'coil', coilType: 'OUTPUT', address: 'Q:0.0' },
],
};
engine.loadProgram({ name: 'test', rungs: [rung], cycleTime: 100 });
// Scan 1: Input true, Pulse starts
engine.setInput('I:0.0', true);
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(true);
// Scan 2: Input false, series is broken, output OFF immediately
engine.setInput('I:0.0', false);
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(false);
expect(engine.getState().timers.get('T1')?.accumulated).toBe(100);
});
});
describe('CTU (Counter Up)', () => {
it('should increment on rising edge of rung', () => {
const rung: Rung = {
id: 'r1',
enabled: true,
series: [
{ type: 'contact', contactType: 'NO', address: 'I:0.0' },
{ type: 'counter', counterType: 'CTU', instanceId: 'C1', preset: 2 , current: 0},
{ type: 'coil', coilType: 'OUTPUT', address: 'Q:0.0' },
],
};
engine.loadProgram({ name: 'test', rungs: [rung], cycleTime: 100 });
// Scan 1: Input true, count = 1
engine.setInput('I:0.0', true);
engine.scan();
expect(engine.getState().counters.get('C1')?.current).toBe(1);
expect(engine.getOutput('Q:0.0')).toBe(false);
// Scan 2: Input false, count remains 1
engine.setInput('I:0.0', false);
engine.scan();
expect(engine.getState().counters.get('C1')?.current).toBe(1);
// Scan 3: Input true, count = 2 (Preset reached)
engine.setInput('I:0.0', true);
engine.scan();
expect(engine.getState().counters.get('C1')?.current).toBe(2);
expect(engine.getOutput('Q:0.0')).toBe(true);
});
});
describe('CTD (Counter Down)', () => {
it('should decrement on rising edge of rung', () => {
const rung: Rung = {
id: 'r1',
enabled: true,
series: [
{ type: 'contact', contactType: 'NO', address: 'I:0.0' },
{ type: 'counter', counterType: 'CTD', instanceId: 'C1', preset: 1 , current: 0},
{ type: 'coil', coilType: 'OUTPUT', address: 'Q:0.0' },
],
};
engine.loadProgram({ name: 'test', rungs: [rung], cycleTime: 100 });
engine.state.counters.set('C1', { address: 'C1', current: 2, ...rung.series[0] } as any);
// Scan 1: Input true, count = 1 (not done yet: 1 > 0)
engine.setInput('I:0.0', true);
engine.scan();
expect(engine.getState().counters.get('C1')?.current).toBe(1);
expect(engine.getOutput('Q:0.0')).toBe(false);
// Scan 2: Input false, count remains 1
engine.setInput('I:0.0', false);
engine.scan();
expect(engine.getState().counters.get('C1')?.current).toBe(1);
// Scan 3: Input true, count = 0 (Done: 0 <= 0, Allen Bradley CTD DN bit)
engine.setInput('I:0.0', true);
engine.scan();
expect(engine.getState().counters.get('C1')?.current).toBe(0);
expect(engine.getOutput('Q:0.0')).toBe(true);
});
});
});