Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
/**
* End-to-end regression for issue #944 ("Type Conversion TO_INT not
* working") at the level `generateGraphicalPou` actually runs at: a full
* `TranspilePou` + `TranspileProject`, exactly like the compile pipeline
* (`backend/shared/compile/pipeline.ts`) feeds it.
*
* Reproduces the reported project shape: a PROGRAM with a REAL variable
* (`O_R`) wired into a `TO_INT` conversion block on an FBD network,
* feeding an INT output variable — the exact pattern that produced
* `_TMP_TO_INT6913197_OUT := TO_INT(O_R);` in the issue's generated
* `webserver_program.st` and made matiec fail with `';' missing at the
* end of statement`.
*/

import type { RFNode } from '../../walker/types'
import type { TranspilePou, TranspileProject } from '../../types'
import { generateGraphicalPou } from '../pou-graphical'

function inputVariableNode(id: string, name: string): RFNode {
return {
id,
type: 'input-variable',
position: { x: 0, y: 0 },
data: { variant: 'input-variable', variable: { name }, executionOrder: 0 },
}
}

function outputVariableNode(id: string, name: string): RFNode {
return {
id,
type: 'output-variable',
position: { x: 100, y: 0 },
data: { variant: 'output-variable', variable: { name }, executionOrder: 0 },
}
}

function conversionBlockNode(id: string, typeName: string, numericId: string): RFNode {
const destinationType = typeName.match(/^TO_([A-Z][A-Z0-9]*)$/)?.[1] ?? 'ANY'
return {
id,
type: 'block',
position: { x: 50, y: 0 },
data: {
variant: {
name: typeName,
type: 'function',
variables: [
{ name: 'IN', class: 'input', type: { definition: 'base-type', value: 'ANY' } },
{ name: 'OUT', class: 'output', type: { definition: 'base-type', value: destinationType } },
],
},
numericId,
executionOrder: 0,
},
}
}

function emptyProject(pous: TranspilePou[]): TranspileProject {
return {
dataTypes: [],
pous,
configuration: { tasks: [], instances: [], globalVariables: [] },
}
}

describe('generateGraphicalPou — polymorphic conversion call resolution (issue #944)', () => {
it('emits REAL_TO_INT, not the bare TO_INT shorthand, for a REAL local wired into a TO_INT block', () => {
const pou: TranspilePou = {
name: 'main',
pouType: 'program',
interface: {
variables: [
{ name: 'O_R', type: { definition: 'base-type', value: 'REAL' }, class: 'local' },
{ name: 'OFFICE_TEMP', type: { definition: 'base-type', value: 'INT' }, class: 'local' },
],
},
body: {
language: 'fbd',
value: {
rung: {
nodes: [
inputVariableNode('in1', 'O_R'),
conversionBlockNode('blk1', 'TO_INT', '6913197'),
outputVariableNode('out1', 'OFFICE_TEMP'),
],
edges: [
{ id: 'e1', source: 'in1', target: 'blk1', targetHandle: 'IN' },
{ id: 'e2', source: 'blk1', target: 'out1', sourceHandle: 'OUT' },
],
},
},
},
}

const chunks = generateGraphicalPou(pou, emptyProject([pou]))
const text = chunks.map((c) => c[0]).join('')

expect(text).toContain('REAL_TO_INT(O_R)')
expect(text).not.toMatch(/:=\s*TO_INT\(/)
// The output temp's declared type must also resolve off ANY (the
// sibling defect PR #854 fixed) — belt-and-suspenders check that
// this change didn't regress that.
expect(text).toMatch(/_TMP_TO_INT6913197_OUT\s*:\s*INT/)
})

it('resolves the source type from a project global variable when the POU has no matching local', () => {
const pou: TranspilePou = {
name: 'main',
pouType: 'program',
interface: {
variables: [{ name: 'OFFICE_TEMP', type: { definition: 'base-type', value: 'INT' }, class: 'local' }],
},
body: {
language: 'fbd',
value: {
rung: {
nodes: [
inputVariableNode('in1', 'G_TEMP'),
conversionBlockNode('blk1', 'TO_INT', '42'),
outputVariableNode('out1', 'OFFICE_TEMP'),
],
edges: [
{ id: 'e1', source: 'in1', target: 'blk1', targetHandle: 'IN' },
{ id: 'e2', source: 'blk1', target: 'out1', sourceHandle: 'OUT' },
],
},
},
},
}
const project = emptyProject([pou])
project.configuration.globalVariables.push({
name: 'G_TEMP',
type: { definition: 'base-type', value: 'REAL' },
class: 'external',
})

const chunks = generateGraphicalPou(pou, project)
const text = chunks.map((c) => c[0]).join('')

expect(text).toContain('REAL_TO_INT(G_TEMP)')
})

it('prefers a POU-local variable over a project global with the same name', () => {
const pou: TranspilePou = {
name: 'main',
pouType: 'program',
interface: {
variables: [
{ name: 'SHADOWED', type: { definition: 'base-type', value: 'BOOL' }, class: 'local' },
{ name: 'OFFICE_TEMP', type: { definition: 'base-type', value: 'INT' }, class: 'local' },
],
},
body: {
language: 'fbd',
value: {
rung: {
nodes: [
inputVariableNode('in1', 'SHADOWED'),
conversionBlockNode('blk1', 'TO_INT', '44'),
outputVariableNode('out1', 'OFFICE_TEMP'),
],
edges: [
{ id: 'e1', source: 'in1', target: 'blk1', targetHandle: 'IN' },
{ id: 'e2', source: 'blk1', target: 'out1', sourceHandle: 'OUT' },
],
},
},
},
}
const project = emptyProject([pou])
project.configuration.globalVariables.push({
name: 'SHADOWED',
type: { definition: 'base-type', value: 'REAL' },
class: 'external',
})

const text = generateGraphicalPou(pou, project)
.map((chunk) => chunk[0])
.join('')

expect(text).toContain('BOOL_TO_INT(SHADOWED)')
expect(text).not.toContain('REAL_TO_INT(SHADOWED)')
})

it('does not index derived variable types as conversion sources', () => {
const pou: TranspilePou = {
name: 'main',
pouType: 'program',
interface: {
variables: [
{ name: 'DERIVED_TEMP', type: { definition: 'derived', value: 'REAL_ALIAS' }, class: 'local' },
{ name: 'OFFICE_TEMP', type: { definition: 'base-type', value: 'INT' }, class: 'local' },
],
},
body: {
language: 'fbd',
value: {
rung: {
nodes: [
inputVariableNode('in1', 'DERIVED_TEMP'),
conversionBlockNode('blk1', 'TO_INT', '43'),
outputVariableNode('out1', 'OFFICE_TEMP'),
],
edges: [
{ id: 'e1', source: 'in1', target: 'blk1', targetHandle: 'IN' },
{ id: 'e2', source: 'blk1', target: 'out1', sourceHandle: 'OUT' },
],
},
},
},
}

const chunks = generateGraphicalPou(pou, emptyProject([pou]))
const text = chunks.map((c) => c[0]).join('')

expect(text).toContain('TO_INT(DERIVED_TEMP)')
expect(text).not.toContain('REAL_ALIAS_TO_INT(DERIVED_TEMP)')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,9 @@ function collectBlockSignatures(project: TranspileProject): Map<string, BlockInf
// GetVariableType + GetBlockType ports (PLCGenerator.py:786-817, PLCControler.py:1288-1335)
function buildTypeContext(pou: TranspilePou, project: TranspileProject): TypeContext {
const interfaceTypes = new Map<string, string>()
for (const v of project.configuration.globalVariables) {
interfaceTypes.set(v.name, getTypeAsText(v))
}
for (const v of pou.interface?.variables ?? []) {
interfaceTypes.set(v.name, getTypeAsText(v))
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { blockInfosFromVariant, resolveConversionFunctionName } from '../block-library'

describe('blockInfosFromVariant', () => {
it('rejects malformed variants and variants without a string name', () => {
expect(blockInfosFromVariant(null)).toBeNull()
expect(blockInfosFromVariant([])).toBeNull()
expect(blockInfosFromVariant({})).toBeNull()
expect(blockInfosFromVariant({ name: 42 })).toBeNull()
})

it('defaults omitted fields on a valid function variant', () => {
expect(blockInfosFromVariant({ name: 'CUSTOM_FUNCTION' })).toEqual({
name: 'CUSTOM_FUNCTION',
type: 'function',
extensible: false,
inputs: [],
outputs: [],
comment: '',
usage: '',
})
})

it('normalizes valid variable records and ignores malformed or control-pin entries', () => {
expect(
blockInfosFromVariant({
name: 'CUSTOM_BLOCK',
type: 'function-block-instance',
extensible: true,
variables: [
null,
{ class: 'input' },
{ name: 'EN', class: 'input', type: { value: 'BOOL' } },
{ name: 'ENO', class: 'output', type: { value: 'BOOL' } },
{ name: 'IN', class: 'input', type: { value: 'REAL' } },
{ name: 'OUT', class: 'output', type: { value: 42 } },
{ name: 'BOTH', class: 'inOut', type: { value: 'INT' } },
{ name: 'LEGACY_BOTH', class: 'inout' },
{ name: 'IGNORED', class: 'local', type: { value: 'BOOL' } },
],
}),
).toEqual({
name: 'CUSTOM_BLOCK',
type: 'functionBlock',
extensible: true,
inputs: [
{ name: 'IN', type: 'REAL', qualifier: 'none' },
{ name: 'BOTH', type: 'INT', qualifier: 'none' },
{ name: 'LEGACY_BOTH', type: 'ANY', qualifier: 'none' },
],
outputs: [
{ name: 'OUT', type: 'ANY', qualifier: 'none' },
{ name: 'BOTH', type: 'INT', qualifier: 'none' },
{ name: 'LEGACY_BOTH', type: 'ANY', qualifier: 'none' },
],
comment: '',
usage: '',
})
})
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

describe('resolveConversionFunctionName', () => {
it('resolves a supported concrete conversion', () => {
expect(resolveConversionFunctionName('TO_INT', 'REAL')).toBe('REAL_TO_INT')
})

it('rejects an unknown TO_<TYPE> shorthand even if it matches the name pattern', () => {
expect(resolveConversionFunctionName('TO_FOO', 'REAL')).toBeNull()
})

it('rejects unsupported temporal conversions', () => {
expect(resolveConversionFunctionName('TO_TIME', 'DATE')).toBeNull()
})

it('supports BCD conversions only for unsigned integer types', () => {
expect(resolveConversionFunctionName('TO_UINT', 'BCD')).toBe('BCD_TO_UINT')
expect(resolveConversionFunctionName('TO_BCD', 'UDINT')).toBe('UDINT_TO_BCD')
expect(resolveConversionFunctionName('TO_BCD', 'INT')).toBeNull()
})

it('covers identity, boolean, and temporal conversion exits', () => {
expect(resolveConversionFunctionName('INT_TO_REAL', 'INT')).toBeNull()
expect(resolveConversionFunctionName('TO_INT', 'INT')).toBeNull()
expect(resolveConversionFunctionName('TO_BOOL', 'TIME')).toBeNull()
expect(resolveConversionFunctionName('TO_BOOL', 'INT')).toBe('INT_TO_BOOL')
expect(resolveConversionFunctionName('TO_TIME', 'INT')).toBe('INT_TO_TIME')
expect(resolveConversionFunctionName('TO_DATE', 'DATE_AND_TIME')).toBe('DATE_AND_TIME_TO_DATE')
})
})
Loading