import { describe, it, expect, beforeEach } from 'vitest';
import { LadderEngine } from './engine';
import type { Program, Rung } from './types';
describe('LadderEngine', () => {
let engine: LadderEngine;
beforeEach(() => {
engine = new LadderEngine();
});
// ─── I/O Operations ────────────────────────────────────────────
describe('setInput / getInput', () => {
it('sets and gets boolean inputs', () => {
engine.setInput('I:0.0', true);
expect(engine.getInput('I:0.0')).toBe(true);
engine.setInput('I:0.0', false);
expect(engine.getInput('I:0.0')).toBe(false);
});
it('sets and gets numeric inputs with raw values', () => {
engine.setInput('SENSOR:dist', 15.5, 15.5);
expect(engine.getInput('SENSOR:dist')).toBe(15.5);
});
it('returns false for unknown inputs', () => {
expect(engine.getInput('UNKNOWN')).toBe(false);
});
});
describe('setMemory / getMemory', () => {
it('stores and retrieves memory bits', () => {
engine.setMemory('M:0.0', true);
expect(engine.getMemory('M:0.0')).toBe(true);
engine.setMemory('M:0.0', false);
expect(engine.getMemory('M:0.0')).toBe(false);
});
it('defaults unknown memory to false', () => {
expect(engine.getMemory('M:99.99')).toBe(false);
});
});
// ─── Contact Evaluation ────────────────────────────────────────
describe('contacts', () => {
it('NO contact passes when input is true', () => {
const rung: Rung = {
id: 'r1',
enabled: true,
series: [
{ type: 'contact', contactType: 'NO', address: 'I:0.0' },
{ type: 'coil', coilType: 'OUTPUT', address: 'Q:0.0' },
],
};
const program: Program = { name: 'test', rungs: [rung], cycleTime: 100 };
engine.loadProgram(program);
engine.setInput('I:0.0', true);
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(true);
});
it('NO contact blocks when input is false', () => {
const rung: Rung = {
id: 'r1',
enabled: true,
series: [
{ type: 'contact', contactType: 'NO', address: 'I:0.0' },
{ type: 'coil', coilType: 'OUTPUT', address: 'Q:0.0' },
],
};
const program: Program = { name: 'test', rungs: [rung], cycleTime: 100 };
engine.loadProgram(program);
engine.setInput('I:0.0', false);
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(false);
});
it('NC contact passes when input is false', () => {
const rung: Rung = {
id: 'r1',
enabled: true,
series: [
{ type: 'contact', contactType: 'NC', address: 'I:0.0' },
{ type: 'coil', coilType: 'OUTPUT', address: 'Q:0.0' },
],
};
const program: Program = { name: 'test', rungs: [rung], cycleTime: 100 };
engine.loadProgram(program);
engine.setInput('I:0.0', false);
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(true);
});
it('NC contact blocks when input is true', () => {
const rung: Rung = {
id: 'r1',
enabled: true,
series: [
{ type: 'contact', contactType: 'NC', address: 'I:0.0' },
{ type: 'coil', coilType: 'OUTPUT', address: 'Q:0.0' },
],
};
const program: Program = { name: 'test', rungs: [rung], cycleTime: 100 };
engine.loadProgram(program);
engine.setInput('I:0.0', true);
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(false);
});
it('contact with comparison condition evaluates correctly', () => {
const rung: Rung = {
id: 'r1',
enabled: true,
series: [
{
type: 'contact',
contactType: 'NO',
address: 'SENSOR:dist',
condition: { op: '<', value: 20 },
},
{ type: 'coil', coilType: 'OUTPUT', address: 'MOTOR:B' },
],
};
const program: Program = { name: 'test', rungs: [rung], cycleTime: 100 };
engine.loadProgram(program);
// Distance 15 < 20 → true → motor on
engine.setInput('SENSOR:dist', 15, 15);
engine.scan();
expect(engine.getOutput('MOTOR:B')).toBe(true);
// Distance 25 < 20 → false → motor off
engine.setInput('SENSOR:dist', 25, 25);
engine.scan();
expect(engine.getOutput('MOTOR:B')).toBe(false);
});
});
// ─── Coil Types ────────────────────────────────────────────────
describe('coils', () => {
it('OUTPUT coil follows rung logic', () => {
const rung: Rung = {
id: 'r1',
enabled: true,
series: [
{ type: 'contact', contactType: 'NO', address: 'I:0.0' },
{ type: 'coil', coilType: 'OUTPUT', address: 'Q:0.0' },
],
};
engine.loadProgram({ name: 'test', rungs: [rung], cycleTime: 100 });
engine.setInput('I:0.0', true);
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(true);
engine.setInput('I:0.0', false);
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(false);
});
it('SET coil latches on and stays until RESET', () => {
const setRung: Rung = {
id: 'r1',
enabled: true,
series: [
{ type: 'contact', contactType: 'NO', address: 'I:0.0' },
{ type: 'coil', coilType: 'SET', address: 'Q:0.0' },
],
};
const resetRung: Rung = {
id: 'r2',
enabled: true,
series: [
{ type: 'contact', contactType: 'NO', address: 'I:0.1' },
{ type: 'coil', coilType: 'RESET', address: 'Q:0.0' },
],
};
engine.loadProgram({ name: 'test', rungs: [setRung, resetRung], cycleTime: 100 });
// Set
engine.setInput('I:0.0', true);
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(true);
// Input goes away but output stays latched
engine.setInput('I:0.0', false);
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(true);
// Reset
engine.setInput('I:0.1', true);
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(false);
});
it('TOGGLE coil toggles on activation', () => {
const rung: Rung = {
id: 'r1',
enabled: true,
series: [
{ type: 'contact', contactType: 'NO', address: 'I:0.0' },
{ type: 'coil', coilType: 'TOGGLE', address: 'Q:0.0' },
],
};
engine.loadProgram({ name: 'test', rungs: [rung], cycleTime: 100 });
// Rising edge: first toggle (false → true)
engine.setInput('I:0.0', true);
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(true);
// Falling edge: deactivate (no toggle)
engine.setInput('I:0.0', false);
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(true);
// Rising edge: second toggle (false → true)
engine.setInput('I:0.0', true);
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(false);
});
it('TOGGLE coil only fires on rising edge — not every scan while held', () => {
const rung: Rung = {
id: 'r1',
enabled: true,
series: [
{ type: 'contact', contactType: 'NO', address: 'I:0.0' },
{ type: 'coil', coilType: 'TOGGLE', address: 'Q:0.0' },
],
};
engine.loadProgram({ name: 'test', rungs: [rung], cycleTime: 100 });
// Rising edge: false → true, first toggle
engine.setInput('I:0.0', true);
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(true);
// Held high — multiple scans should NOT toggle again
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(true);
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(true);
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(true);
// Falling edge: true → false (no toggle)
engine.setInput('I:0.0', false);
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(true);
// Rising edge again: false → true, second toggle
engine.setInput('I:0.0', true);
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(false);
// Held high again — should NOT toggle
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(false);
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(false);
});
it('OUTPUT coil with value sets numeric output', () => {
const rung: Rung = {
id: 'r1',
enabled: true,
series: [
{ type: 'contact', contactType: 'NO', address: 'I:0.0' },
{ type: 'coil', coilType: 'OUTPUT', address: 'MOTOR:B', value: 75 },
],
};
engine.loadProgram({ name: 'test', rungs: [rung], cycleTime: 100 });
engine.setInput('I:0.0', true);
engine.scan();
expect(engine.getOutput('MOTOR:B')).toBe(75);
});
});
// ─── Series Logic (AND) ────────────────────────────────────────
// ─── Parallel Logic (OR/AND) ────────────────────────────────────
describe('parallel branches (OR/AND logic)', () => {
it('any branch true energizes output (OR logic)', () => {
const rung: Rung = {
id: 'r1',
enabled: true,
series: [
{
type: 'branch',
id: 'b1',
logic: 'OR',
paths: [
[{ type: 'contact', contactType: 'NO', address: 'I:0.0' }],
[{ type: 'contact', contactType: 'NO', address: 'I:0.1' }],
],
},
{ type: 'coil', coilType: 'OUTPUT', address: 'Q:0.0' },
],
};
engine.loadProgram({ name: 'test', rungs: [rung], cycleTime: 100 });
engine.setInput('I:0.0', false);
engine.setInput('I:0.1', true);
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(true);
engine.setInput('I:0.1', false);
engine.setInput('I:0.0', true);
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(true);
});
it('AND branch requires all paths true', () => {
const rung: Rung = {
id: 'r1',
enabled: true,
series: [
{
type: 'branch',
id: 'b1',
logic: 'AND',
paths: [
[{ type: 'contact', contactType: 'NO', address: 'I:0.0' }],
[{ type: 'contact', contactType: 'NO', address: 'I:0.1' }],
],
},
{ type: 'coil', coilType: 'OUTPUT', address: 'Q:0.0' },
],
};
engine.loadProgram({ name: 'test', rungs: [rung], cycleTime: 100 });
engine.setInput('I:0.0', true);
engine.setInput('I:0.1', false);
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(false);
engine.setInput('I:0.1', true);
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(true);
});
});
// ─── Parallel Logic (OR) ───────────────────────────────────────
// ─── Parallel Logic (OR/AND) ────────────────────────────────────
describe('parallel branches (OR/AND logic)', () => {
it('any branch true energizes output (OR logic)', () => {
const rung: Rung = {
id: 'r1',
enabled: true,
series: [
{
type: 'branch',
id: 'b1',
logic: 'OR',
paths: [
[{ type: 'contact', contactType: 'NO', address: 'I:0.0' }],
[{ type: 'contact', contactType: 'NO', address: 'I:0.1' }],
],
},
{ type: 'coil', coilType: 'OUTPUT', address: 'Q:0.0' },
],
};
engine.loadProgram({ name: 'test', rungs: [rung], cycleTime: 100 });
engine.setInput('I:0.0', false);
engine.setInput('I:0.1', true);
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(true);
engine.setInput('I:0.1', false);
engine.setInput('I:0.0', true);
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(true);
});
it('AND branch requires all paths true', () => {
const rung: Rung = {
id: 'r1',
enabled: true,
series: [
{
type: 'branch',
id: 'b1',
logic: 'AND',
paths: [
[{ type: 'contact', contactType: 'NO', address: 'I:0.0' }],
[{ type: 'contact', contactType: 'NO', address: 'I:0.1' }],
],
},
{ type: 'coil', coilType: 'OUTPUT', address: 'Q:0.0' },
],
};
engine.loadProgram({ name: 'test', rungs: [rung], cycleTime: 100 });
engine.setInput('I:0.0', true);
engine.setInput('I:0.1', false);
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(false);
engine.setInput('I:0.1', true);
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(true);
});
});
// ─── Timers ────────────────────────────────────────────────────
describe('timers', () => {
it('TON timer delays output activation', () => {
const rung: Rung = {
id: 'r1',
enabled: true,
series: [
{ type: 'contact', contactType: 'NO', address: 'I:0.0' },
{
type: 'timer',
timerType: 'TON',
instanceId: 'T1',
preset: 500,
accumulated: 0,
},
{ type: 'coil', coilType: 'OUTPUT', address: 'Q:0.0' },
],
};
engine.loadProgram({ name: 'test', rungs: [rung], cycleTime: 100 });
engine.setInput('I:0.0', true);
engine.state.running = true;
// Cycle 1: accumulated = 100, not yet at 500
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(false);
// Cycle 2: accumulated = 200
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(false);
// Cycle 3: accumulated = 300
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(false);
// Cycle 4: accumulated = 400
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(false);
// Cycle 5: accumulated = 500 >= preset
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(true);
});
});
// ─── Counters ──────────────────────────────────────────────────
describe('counters', () => {
it('CTU counter fires when count reaches preset', () => {
const rung: Rung = {
id: 'r1',
enabled: true,
series: [
{
type: 'counter',
counterType: 'CTU',
instanceId: 'C1',
preset: 3,
current: 0,
},
{ type: 'coil', coilType: 'OUTPUT', address: 'Q:0.0' },
],
};
engine.loadProgram({ name: 'test', rungs: [rung], cycleTime: 100 });
engine.state.running = true;
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(false); // count=1
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(false); // count=2
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(true); // count=3 >= preset
});
it('counter resets when reset address is true', () => {
const rung: Rung = {
id: 'r1',
enabled: true,
series: [
{
type: 'counter',
counterType: 'CTU',
instanceId: 'C1',
preset: 3,
current: 0,
resetAddress: 'I:0.1',
},
{ type: 'coil', coilType: 'OUTPUT', address: 'Q:0.0' },
],
};
engine.loadProgram({ name: 'test', rungs: [rung], cycleTime: 100 });
engine.state.running = true;
engine.scan();
engine.scan();
engine.setInput('I:0.1', true);
engine.scan();
// After reset, count goes to 0
const counter = engine.getState().counters.get('C1');
expect(counter?.current).toBe(0);
});
});
// ─── Logic Gates ──────────────────────────────────────────────
describe('logic gates', () => {
it('AND gate requires all inputs true', () => {
const rung: Rung = {
id: 'r1',
enabled: true,
series: [
{
type: 'gate',
gateType: 'AND',
inputs: ['I:0.0', 'I:0.1'],
outputAddress: 'G:AND',
},
{ type: 'coil', coilType: 'OUTPUT', address: 'Q:0.0' },
],
};
engine.loadProgram({ name: 'test', rungs: [rung], cycleTime: 100 });
engine.setInput('I:0.0', true);
engine.setInput('I:0.1', true);
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(true);
});
it('OR gate requires at least one input true', () => {
const rung: Rung = {
id: 'r1',
enabled: true,
series: [
{
type: 'gate',
gateType: 'OR',
inputs: ['I:0.0', 'I:0.1'],
outputAddress: 'G:OR',
},
{ type: 'coil', coilType: 'OUTPUT', address: 'Q:0.0' },
],
};
engine.loadProgram({ name: 'test', rungs: [rung], cycleTime: 100 });
engine.setInput('I:0.0', false);
engine.setInput('I:0.1', true);
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(true);
});
});
// ─── Multi-Rung Programs ───────────────────────────────────────
describe('multi-rung programs', () => {
it('rungs are evaluated top-to-bottom', () => {
const program: Program = {
name: 'multi',
cycleTime: 100,
rungs: [
{
id: 'r1',
enabled: true,
series: [
{ type: 'contact', contactType: 'NO', address: 'I:0.0' },
{ type: 'coil', coilType: 'OUTPUT', address: 'M:0.0' },
],
},
{
id: 'r2',
enabled: true,
series: [
{ type: 'contact', contactType: 'NO', address: 'M:0.0' },
{ type: 'coil', coilType: 'OUTPUT', address: 'Q:0.0' },
],
},
],
};
engine.loadProgram(program);
engine.setInput('I:0.0', true);
engine.state.running = true;
engine.scan();
// Rung 1 sets M:0.0 = true
expect(engine.getOutput('M:0.0')).toBe(true);
// Rung 2 reads M:0.0 and sets Q:0.0 = true
expect(engine.getOutput('Q:0.0')).toBe(true);
});
});
// ─── Engine Control ────────────────────────────────────────────
describe('start / stop', () => {
it('scan() works without running flag for manual use', () => {
const rung: Rung = {
id: 'r1',
enabled: true,
series: [
{ type: 'contact', contactType: 'NO', address: 'I:0.0' },
{ type: 'coil', coilType: 'OUTPUT', address: 'Q:0.0' },
],
};
engine.loadProgram({ name: 'test', rungs: [rung], cycleTime: 100 });
engine.setInput('I:0.0', true);
// Manual scan works regardless of running flag
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(true);
expect(engine.getState().cycle).toBe(1);
});
it('disabled rungs are skipped', () => {
const rung: Rung = {
id: 'r1',
enabled: false,
series: [
{ type: 'contact', contactType: 'NO', address: 'I:0.0' },
{ type: 'coil', coilType: 'OUTPUT', address: 'Q:0.0' },
],
};
engine.loadProgram({ name: 'test', rungs: [rung], cycleTime: 100 });
engine.setInput('I:0.0', true);
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(false);
});
});
// ─── Disabled Rungs ────────────────────────────────────────────
describe('disabled rungs', () => {
it('disabled rungs are skipped', () => {
const rung: Rung = {
id: 'r1',
enabled: false,
series: [
{ type: 'contact', contactType: 'NO', address: 'I:0.0' },
{ type: 'coil', coilType: 'OUTPUT', address: 'Q:0.0' },
],
};
engine.loadProgram({ name: 'test', rungs: [rung], cycleTime: 100 });
engine.setInput('I:0.0', true);
engine.state.running = true;
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(false);
});
});
});