/**
* Behavioral Fidelity Tests β Studio 5000 compliance
* Covers: OSR, OSF, MOV, SCALE, COMPARE, LATCH/UNLATCH, RTO persistence,
* OV/UND counter bits, integer math mode, TOF off-delay
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { LadderEngine } from './engine';
import type { Program, Rung } from './types';
describe('Studio 5000 Behavioral Fidelity', () => {
let engine: LadderEngine;
beforeEach(() => {
engine = new LadderEngine();
});
// βββ One-Shot Rising (OSR) ββββββββββββββββββββββββββββββββββββββββ
describe('OSR (One-Shot Rising)', () => {
it('fires true for exactly one scan on rising edge', () => {
const rung: Rung = {
id: 'r1',
enabled: true,
series: [
{ type: 'oneshot', address: 'I:0.0', edgeType: 'RISING' },
{ type: 'coil', coilType: 'OUTPUT', address: 'Q:0.0' },
],
};
engine.loadProgram({ name: 'test', rungs: [rung], cycleTime: 100 });
// Scan 1: I:0.0 transitions from false β true β fires
engine.setInput('I:0.0', true);
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(true);
// Scan 2: I:0.0 still true β does NOT fire again
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(false);
// Scan 3: I:0.0 goes false
engine.setInput('I:0.0', false);
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(false);
// Scan 4: I:0.0 transitions false β true again β fires again
engine.setInput('I:0.0', true);
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(true);
});
it('does not fire when input was already true at start', () => {
const rung: Rung = {
id: 'r1',
enabled: true,
series: [
{ type: 'oneshot', address: 'I:0.0', edgeType: 'RISING' },
{ type: 'coil', coilType: 'OUTPUT', address: 'Q:0.0' },
],
};
engine.loadProgram({ name: 'test', rungs: [rung], cycleTime: 100 });
// Input already set before first scan β no previous value to compare against
// First scan from cold start: previousInputs is empty, so prevBool=false, fires once
engine.setInput('I:0.0', true);
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(true); // fires on first scan (falseβtrue edge)
// Second scan: still true, no edge β does not fire
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(false);
});
});
// βββ One-Shot Falling (OSF) ββββββββββββββββββββββββββββββββββββββββ
describe('OSF (One-Shot Falling)', () => {
it('fires true for exactly one scan on falling edge', () => {
const rung: Rung = {
id: 'r1',
enabled: true,
series: [
{ type: 'oneshot', address: 'I:0.0', edgeType: 'FALLING' },
{ type: 'coil', coilType: 'OUTPUT', address: 'Q:0.0' },
],
};
engine.loadProgram({ name: 'test', rungs: [rung], cycleTime: 100 });
// Scan 1: I:0.0 = true, no falling edge
engine.setInput('I:0.0', true);
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(false);
// Scan 2: I:0.0 transitions true β false β fires
engine.setInput('I:0.0', false);
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(true);
// Scan 3: I:0.0 still false β does NOT fire again
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(false);
});
});
// βββ OSR/OSF on memory addresses βββββββββββββββββββββββββββββββββ
describe('OSR on memory address (previousInputs fix)', () => {
it('detects rising edge on a memory address', () => {
const rung: Rung = {
id: 'r1',
enabled: true,
series: [
{ type: 'oneshot', address: 'M:0.0', edgeType: 'RISING' },
{ type: 'coil', coilType: 'OUTPUT', address: 'Q:0.0' },
],
};
engine.loadProgram({ name: 'test', rungs: [rung], cycleTime: 100 });
// Scan 1: M:0.0 = false (initial)
engine.setMemory('M:0.0', false);
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(false);
// Scan 2: M:0.0 transitions false β true β fires
engine.setMemory('M:0.0', true);
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(true);
// Scan 3: M:0.0 still true β does not fire
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(false);
});
});
// βββ MOV βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
describe('MOV (Move)', () => {
it('copies value from source to destination when rung is true', () => {
const rung: Rung = {
id: 'r1',
enabled: true,
series: [
{ type: 'contact', contactType: 'NO', address: 'I:0.0' },
{ type: 'move', source: 'I:val', destination: 'M:result' },
{ 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:val', 42);
engine.scan();
expect(engine.getMemory('M:result')).toBe(42);
expect(engine.getOutput('Q:0.0')).toBe(true);
});
it('does not copy when rung is false', () => {
const rung: Rung = {
id: 'r1',
enabled: true,
series: [
{ type: 'contact', contactType: 'NO', address: 'I:0.0' },
{ type: 'move', source: 'I:val', destination: 'M:result' },
{ type: 'coil', coilType: 'OUTPUT', address: 'Q:0.0' },
],
};
engine.loadProgram({ name: 'test', rungs: [rung], cycleTime: 100 });
engine.setInput('I:0.0', false); // rung is false
engine.setInput('I:val', 99);
engine.setMemory('M:result', 0);
engine.scan();
expect(engine.getMemory('M:result')).toBe(0); // unchanged
});
});
// βββ SCALE (SCL) βββββββββββββββββββββββββββββββββββββββββββββββββ
describe('SCALE (SCL)', () => {
it('scales input linearly to output range', () => {
const rung: Rung = {
id: 'r1',
enabled: true,
series: [
{ type: 'contact', contactType: 'NO', address: 'I:0.0' },
{
type: 'scale',
inputAddress: 'I:raw',
destination: 'M:scaled',
inMin: 0, inMax: 100,
outMin: 0, outMax: 1000,
},
{ 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:raw', 50);
engine.scan();
expect(engine.getMemory('M:scaled')).toBe(500); // 50% of 0-1000
});
it('clamps output to outMin when input is below inMin', () => {
const rung: Rung = {
id: 'r1',
enabled: true,
series: [
{
type: 'scale',
inputAddress: 'I:raw',
destination: 'M:scaled',
inMin: 0, inMax: 100,
outMin: 10, outMax: 200,
},
],
};
engine.loadProgram({ name: 'test', rungs: [rung], cycleTime: 100 });
engine.setInput('I:raw', -50); // below inMin
engine.scan();
expect(engine.getMemory('M:scaled')).toBe(10); // clamped to outMin
});
it('clamps output to outMax when input is above inMax', () => {
const rung: Rung = {
id: 'r1',
enabled: true,
series: [
{
type: 'scale',
inputAddress: 'I:raw',
destination: 'M:scaled',
inMin: 0, inMax: 100,
outMin: 0, outMax: 255,
},
],
};
engine.loadProgram({ name: 'test', rungs: [rung], cycleTime: 100 });
engine.setInput('I:raw', 200); // above inMax
engine.scan();
expect(engine.getMemory('M:scaled')).toBe(255); // clamped to outMax
});
});
// βββ COMPARE (EQU, NEQ, GRT, LES, GEQ, LEQ) βββββββββββββββββββββ
describe('COMPARE', () => {
it('EQU (==) returns true when values are equal', () => {
const rung: Rung = {
id: 'r1',
enabled: true,
series: [
{
type: 'compare',
op: '==',
inputA: 'I:a',
inputB: 'I:b',
outputAddress: 'M:result',
},
{ type: 'coil', coilType: 'OUTPUT', address: 'Q:0.0' },
],
};
engine.loadProgram({ name: 'test', rungs: [rung], cycleTime: 100 });
engine.setInput('I:a', 5);
engine.setInput('I:b', 5);
engine.scan();
expect(engine.getMemory('M:result')).toBe(true);
expect(engine.getOutput('Q:0.0')).toBe(true); // compare passes power when true
});
it('EQU (==) blocks power when values are NOT equal', () => {
const rung: Rung = {
id: 'r1',
enabled: true,
series: [
{
type: 'compare',
op: '==',
inputA: 'I:a',
inputB: 'I:b',
outputAddress: 'M:result',
},
{ type: 'coil', coilType: 'OUTPUT', address: 'Q:0.0' },
],
};
engine.loadProgram({ name: 'test', rungs: [rung], cycleTime: 100 });
engine.setInput('I:a', 5);
engine.setInput('I:b', 10);
engine.scan();
expect(engine.getMemory('M:result')).toBe(false);
expect(engine.getOutput('Q:0.0')).toBe(false); // FIX 1: compare blocks power when false
});
it('GRT (>) passes when A > B', () => {
const rung: Rung = {
id: 'r1',
enabled: true,
series: [
{ type: 'compare', op: '>', inputA: 'I:a', inputB: 'I:b', outputAddress: 'M:res' },
{ type: 'coil', coilType: 'OUTPUT', address: 'Q:0.0' },
],
};
engine.loadProgram({ name: 'test', rungs: [rung], cycleTime: 100 });
engine.setInput('I:a', 10);
engine.setInput('I:b', 5);
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(true);
});
it('LES (<) blocks when A >= B', () => {
const rung: Rung = {
id: 'r1',
enabled: true,
series: [
{ type: 'compare', op: '<', inputA: 'I:a', inputB: 'I:b', outputAddress: 'M:res' },
{ type: 'coil', coilType: 'OUTPUT', address: 'Q:0.0' },
],
};
engine.loadProgram({ name: 'test', rungs: [rung], cycleTime: 100 });
engine.setInput('I:a', 10);
engine.setInput('I:b', 5);
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(false);
});
});
// βββ LATCH / UNLATCH βββββββββββββββββββββββββββββββββββββββββββββ
describe('LATCH / UNLATCH (OTL / OTU)', () => {
it('LATCH coil stays ON after rung goes false', () => {
const rung: Rung = {
id: 'r1',
enabled: true,
series: [
{ type: 'contact', contactType: 'NO', address: 'I:0.0' },
{ type: 'coil', coilType: 'LATCH', address: 'Q:0.0' },
],
};
engine.loadProgram({ name: 'test', rungs: [rung], cycleTime: 100 });
// Energize latch
engine.setInput('I:0.0', true);
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(true);
// Input goes away β latch stays ON
engine.setInput('I:0.0', false);
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(true);
});
it('UNLATCH clears a latched output', () => {
const latchRung: Rung = {
id: 'r1',
enabled: true,
series: [
{ type: 'contact', contactType: 'NO', address: 'I:0.0' },
{ type: 'coil', coilType: 'LATCH', address: 'Q:0.0' },
],
};
const unlatchRung: Rung = {
id: 'r2',
enabled: true,
series: [
{ type: 'contact', contactType: 'NO', address: 'I:0.1' },
{ type: 'coil', coilType: 'UNLATCH', address: 'Q:0.0' },
],
};
engine.loadProgram({ name: 'test', rungs: [latchRung, unlatchRung], cycleTime: 100 });
// Latch
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(true); // still latched
// Unlatch
engine.setInput('I:0.1', true);
engine.scan();
expect(engine.getOutput('Q:0.0')).toBe(false);
});
});
// βββ RTO (Retain On-Delay) persistence βββββββββββββββββββββββββββ
describe('RTO (Retain On-Delay)', () => {
it('accumulates time even when input goes false (retentive)', () => {
const rung: Rung = {
id: 'r1',
enabled: true,
series: [
{ type: 'contact', contactType: 'NO', address: 'I:0.0' },
{ type: 'timer', timerType: 'RTO', 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, accumulate 100ms
engine.setInput('I:0.0', true);
engine.scan();
expect(engine.getState().timers.get('T1')?.accumulated).toBe(100);
// Scan 2: Input false β RTO retains accumulated time (unlike TON which resets to 0)
engine.setInput('I:0.0', false);
engine.scan();
expect(engine.getState().timers.get('T1')?.accumulated).toBe(100); // retained!
// Scan 3: Input true again, accumulates further
engine.setInput('I:0.0', true);
engine.scan();
expect(engine.getState().timers.get('T1')?.accumulated).toBe(200);
// Scan 4: One more scan to reach 300 = preset
engine.scan();
expect(engine.getState().timers.get('T1')?.accumulated).toBe(300);
expect(engine.getOutput('Q:0.0')).toBe(true); // DN bit set
});
it('resets accumulated time when resetAddress is true', () => {
const rung: Rung = {
id: 'r1',
enabled: true,
series: [
{ type: 'contact', contactType: 'NO', address: 'I:0.0' },
{
type: 'timer',
timerType: 'RTO',
instanceId: 'T1',
preset: 300,
accumulated: 0,
resetAddress: 'I:reset',
},
{ type: 'coil', coilType: 'OUTPUT', address: 'Q:0.0' },
],
};
engine.loadProgram({ name: 'test', rungs: [rung], cycleTime: 100 });
// Accumulate some time
engine.setInput('I:0.0', true);
engine.scan();
engine.scan();
expect(engine.getState().timers.get('T1')?.accumulated).toBe(200);
// Assert reset
engine.setInput('I:reset', true);
engine.scan();
expect(engine.getState().timers.get('T1')?.accumulated).toBe(0);
expect(engine.getOutput('Q:0.0')).toBe(false);
});
});
// βββ Counter OV/UND bits βββββββββββββββββββββββββββββββββββββββββ
describe('Counter OV/UND bits', () => {
it('sets OV bit and clamps to 9999 when CTU overflows', () => {
const rung: Rung = {
id: 'r1',
enabled: true,
series: [
{ type: 'contact', contactType: 'NO', address: 'I:0.0' },
{ type: 'counter', counterType: 'CTU', instanceId: 'C1', preset: 9999, current: 0 },
{ type: 'coil', coilType: 'OUTPUT', address: 'Q:0.0' },
],
};
engine.loadProgram({ name: 'test', rungs: [rung], cycleTime: 100 });
// Set counter to 9999 already
engine.state.counters.set('C1', {
type: 'counter', counterType: 'CTU', instanceId: 'C1',
preset: 9999, current: 9999,
});
// One more rising edge should try to increment to 10000
engine.setInput('I:0.0', true);
engine.scan();
const counter = engine.getState().counters.get('C1');
expect(counter?.current).toBe(9999); // clamped
expect(counter?.ov).toBe(true); // overflow flag set
});
it('sets UND bit and clamps to 0 when CTD underflows', () => {
const rung: Rung = {
id: 'r1',
enabled: true,
series: [
{ type: 'contact', contactType: 'NO', address: 'I:0.0' },
{ type: 'counter', counterType: 'CTD', instanceId: 'C1', preset: 0, current: 0 },
{ type: 'coil', coilType: 'OUTPUT', address: 'Q:0.0' },
],
};
engine.loadProgram({ name: 'test', rungs: [rung], cycleTime: 100 });
// Set counter to 0
engine.state.counters.set('C1', {
type: 'counter', counterType: 'CTD', instanceId: 'C1',
preset: 0, current: 0,
});
// One more decrement should try to go to -1
engine.setInput('I:0.0', true);
engine.scan();
const counter = engine.getState().counters.get('C1');
expect(counter?.current).toBe(0); // clamped to 0
expect(counter?.und).toBe(true); // underflow flag set
});
it('clears OV and UND bits on reset', () => {
const rung: Rung = {
id: 'r1',
enabled: true,
series: [
{ type: 'contact', contactType: 'NO', address: 'I:0.0' },
{
type: 'counter', counterType: 'CTU', instanceId: 'C1',
preset: 9999, current: 0, resetAddress: 'I:reset',
},
{ type: 'coil', coilType: 'OUTPUT', address: 'Q:0.0' },
],
};
engine.loadProgram({ name: 'test', rungs: [rung], cycleTime: 100 });
// Manually set OV bit
engine.state.counters.set('C1', {
type: 'counter', counterType: 'CTU', instanceId: 'C1',
preset: 9999, current: 9999, ov: true,
});
// Reset
engine.setInput('I:reset', true);
engine.scan();
const counter = engine.getState().counters.get('C1');
expect(counter?.current).toBe(0);
expect(counter?.ov).toBe(false);
});
});
// βββ Integer Math Mode ββββββββββββββββββββββββββββββββββββββββββββ
describe('Integer Math Mode', () => {
it('truncates float result when integerMode=true', () => {
const rung: Rung = {
id: 'r1',
enabled: true,
series: [
{ type: 'math', operator: 'DIV', inputA: 'I:a', inputB: 'I:b', outputAddress: 'M:result' },
],
};
engine.loadProgram({ name: 'test', rungs: [rung], cycleTime: 100, integerMode: true });
engine.setInput('I:a', 10);
engine.setInput('I:b', 3);
engine.scan();
expect(engine.getMemory('M:result')).toBe(3); // Math.trunc(3.333...) = 3
});
it('keeps float result when integerMode is not set', () => {
const rung: Rung = {
id: 'r1',
enabled: true,
series: [
{ type: 'math', operator: 'DIV', inputA: 'I:a', inputB: 'I:b', outputAddress: 'M:result' },
],
};
engine.loadProgram({ name: 'test', rungs: [rung], cycleTime: 100 }); // no integerMode
engine.setInput('I:a', 10);
engine.setInput('I:b', 3);
engine.scan();
expect(engine.getMemory('M:result')).toBeCloseTo(3.333, 2);
});
it('clamps result to 32-bit signed integer range', () => {
const rung: Rung = {
id: 'r1',
enabled: true,
series: [
{ type: 'math', operator: 'MUL', inputA: 'I:a', inputB: 'I:b', outputAddress: 'M:result' },
],
};
engine.loadProgram({ name: 'test', rungs: [rung], cycleTime: 100, integerMode: true });
engine.setInput('I:a', 2000000000);
engine.setInput('I:b', 2); // 4 billion > 2^31-1
engine.scan();
expect(engine.getMemory('M:result')).toBe(2147483647); // clamped to INT32_MAX
});
});
});