Skip to content
Merged
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
533 changes: 529 additions & 4 deletions src/backend/shared/compile/__tests__/compute-io-image.test.ts

Large diffs are not rendered by default.

97 changes: 97 additions & 0 deletions src/backend/shared/compile/__tests__/pipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,52 @@ describe('runCompilePipeline — I/O image gate', () => {
...overrides,
})

it('WARNS about two writers on one output and still compiles', async () => {
// IEC 61131-3 does not forbid declaring one located variable in two POUs,
// so the editor does not either -- which write survives is the
// programmer's business. What the compile owes them is the fact that the
// addresses are global and POU order decides the winner, which neither
// declaration shows on its own. So: a warning, and the build carries on.
const port = makePort()
const { events, emit } = captureEvents()

const twoWriters = {
...projectDataFixture,
pous: [
pouLocating('%QW3'),
{
type: 'program',
data: {
name: 'second',
language: 'st',
documentation: '',
body: { language: 'st', value: '' },
variables: [{ name: 'pump', location: '%QW3' }],
},
},
],
} as unknown as PLCProjectData

const result = await runCompilePipeline(
arduinoArgs({
projectData: twoWriters,
devicePinMapping: [{ pin: '3', pinType: 'analogOutput', address: '%QW3' }] as DevicePin[],
}),
port,
emit,
)

expect(result.success).toBe(true)
const warned = events.filter((e) => e.message.includes('drive the same output'))
expect(warned).toHaveLength(1)
expect(warned[0].level).toBe('warning')
// Both names, because either one may be the mistake.
expect(warned[0].message).toContain('valve')
expect(warned[0].message).toContain('pump')
// And it never says the compiler refuses it, because it does not.
expect(warned[0].message).not.toContain('refuse')
})

it('bails before transpilation when an output declaration has no producer', async () => {
const port = makePort()
const { events, emit } = captureEvents()
Expand Down Expand Up @@ -510,6 +556,57 @@ describe('runCompilePipeline — I/O image gate', () => {
expect(defines).toContain('#define MAX_MEMORY_WORD 0')
})

it('says in the build log where each sized area came from', async () => {
// The size on its own is untraceable: three contributors can size an area
// and the image takes the largest, so %QW = 4 might be the program's
// producers or a Modbus config nobody has opened in a year. Naming the
// source is what stops the user guessing which one to change.
const port = makePort()
const { events, emit } = captureEvents()

await runCompilePipeline(
arduinoArgs({
projectData: withPou('%QW3'),
devicePinMapping: [
{ pin: '3', pinType: 'analogOutput', address: '%QW3' },
{ pin: '2', pinType: 'digitalInput', address: '%IX0.0' },
] as DevicePin[],
}),
port,
emit,
)

const lines = events.filter((e) => e.message.includes('sized to')).map((e) => e.message)
expect(lines).toEqual(['%IX sized to 1 bit from address producers', '%QW sized to 4 words from address producers'])
})

it('says nothing about sizes for a target that keeps its firmware defaults', async () => {
// v3 and the simulator are not sized, so a line here would describe an
// image neither of them receives.
//
// %MW7 and not an output address: memory is its own producer, so it sizes
// an area with no pins and no server in the project. An output would size
// nothing here whatever the gate did, and the test would pass without
// exercising it — which is exactly what it did before this comment.
const port = makePort()
const { events, emit } = captureEvents()

await runCompilePipeline(
makeArgs({
projectData: withPou('%MW7'),
isSimulator: false,
isRuntimeV3: true,
boardRuntime: 'openplc-compiler',
boardTarget: 'OpenPLC Runtime v3',
compileOnly: true,
}),
port,
emit,
)

expect(events.filter((e) => e.message.includes('sized to'))).toEqual([])
})

it('leaves the simulator defines.h without a process image block', async () => {
// It keeps openplc.h's own fallbacks, so its defines.h is byte-identical
// to what it was before any of this existed.
Expand Down
40 changes: 40 additions & 0 deletions src/backend/shared/compile/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ import type { PLCProjectData } from '../types/PLC/open-plc'
import { buildCBlocksFromPous, composeFirmwareBundle } from './steps/compose-firmware-bundle'
import {
computeIoImage,
describeDuplicateOutput,
describeIoImageSizes,
describeUnbackedLocation,
describeUnsupportedArea,
IMAGE_AREAS_BAREMETAL,
Expand Down Expand Up @@ -473,6 +475,44 @@ async function runCompilePipelineInner(
return bailError(emit, 'validate', 'Compilation aborted: every located variable needs an address that exists.')
}

// TWO WRITERS ON ONE OUTPUT IS A WARNING, NOT A REFUSAL.
//
// IEC 61131-3 does not forbid it: a located variable may be declared in more
// than one POU, and which write survives is then the programmer's business,
// not the editor's. Where the standard does not restrict, neither do we.
//
// It is still worth saying. The addresses are global, so the last write in
// the scan wins and which one that is depends on POU order — a fact that is
// invisible in either declaration on its own. So the compile reports it and
// continues, and the amber glyph says the same thing at edit time.
if (sizesTheImage) {
for (const issue of ioImage.duplicateOutputs) {
emit({ stage: 'validate', message: describeDuplicateOutput(issue), level: 'warning' })
}
}

// WHERE EACH NUMBER CAME FROM, not just what it is.
//
// Both emitters are downstream of here — the `#define` block for bare metal
// and `image.conf` for v4 — so this is the one place that serves both, the
// same reason the sizer itself is called here rather than in each branch.
//
// The size alone is untraceable. Three contributors can size an area and the
// image takes the largest, so `%QW = 1024` might be the program's producers
// or might be a Modbus slave config nobody has opened in a year, and which
// one it is decides what the user changes. That is sharpest for a project
// that came from somewhere else: it arrives with a `bufferMapping` and an
// `s7commSlaveConfig` already in it, and nothing in the editor says so.
//
// Info level and only for the targets that actually size: v3 and the
// simulator keep their firmware defaults, so a line here would describe an
// image neither of them receives.
if (sizesTheImage) {
for (const line of describeIoImageSizes(ioImage)) {
emit({ stage: 'validate', message: line, level: 'info' })
}
}

// ---------------------------------------------------------------------
// Step 0: Use the already-preprocessed project data.
//
Expand Down
Loading