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
8 changes: 5 additions & 3 deletions src/frontend/store/__tests__/element-duplicate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,10 +357,12 @@ describe('pouActions.duplicate', () => {
})

it('registers a duplicated function as a function', () => {
useOpenPLCStore.getState().pouActions.create({ type: 'function', name: 'Scale', language: 'st' })
useOpenPLCStore.getState().pouActions.duplicate('Scale', 'Scale_copy')
// Not `Scale`: the test harness seeds the real bundled libraries, and SCALE is a
// function in oscat-basic and plcopen-softmotion, so the create is refused.
useOpenPLCStore.getState().pouActions.create({ type: 'function', name: 'Scaler', language: 'st' })
useOpenPLCStore.getState().pouActions.duplicate('Scaler', 'Scaler_copy')

expect(useOpenPLCStore.getState().libraries.user.find((l) => l.name === 'Scale_copy')?.type).toBe('function')
expect(useOpenPLCStore.getState().libraries.user.find((l) => l.name === 'Scaler_copy')?.type).toBe('function')
})

it('does not register a duplicated program as a library block', () => {
Expand Down
155 changes: 154 additions & 1 deletion src/frontend/store/__tests__/shared-slice.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createStore } from 'zustand/vanilla'

import type { PLCVariable } from '../../../middleware/shared/ports/types'
import type { PLCProjectData, PLCVariable } from '../../../middleware/shared/ports/types'
import { createAISlice } from '../slices/ai'
import { createConsoleSlice } from '../slices/console/slice'
import { createDeviceSlice } from '../slices/device/slice'
Expand Down Expand Up @@ -1199,6 +1199,159 @@ describe('createSharedSlice', () => {
})
})
})

/**
* The bundled archives are always in the build, so their symbols are taken before
* the user creates anything: reusing one emits a duplicate declaration that only
* surfaces as a C++ error in a generated file.
*/
describe('against library symbols', () => {
const librarySymbol = (name: string, type: 'function' | 'function-block') => ({
name,
type,
language: 'st' as const,
variables: [],
body: '',
documentation: '',
})

const seedLibraries = () =>
store.getState().libraryActions.setSystemLibraries([
{
name: 'oscat-basic',
author: 'OSCAT',
version: '3.3.4',
stPath: '',
cPath: '',
pous: [librarySymbol('MATRIX', 'function-block'), librarySymbol('LIMITS_TYPE', 'function-block')],
},
{
name: 'iec-std-functions',
author: 'IEC',
version: '1.0.0',
stPath: '',
cPath: '',
pous: [librarySymbol('SIN', 'function')],
},
])

beforeEach(() => {
seedLibraries()
})

it('refuses a POU create, naming the library and the symbol kind', () => {
const result = store.getState().pouActions.create({ type: 'program', name: 'Matrix', language: 'st' })
expect(result.ok).toBe(false)
expect(result.message).toBe('"Matrix" is a function block in the oscat-basic library')
expect(store.getState().project.data.pous).toHaveLength(0)
})

it('refuses a data type create, case-insensitively', () => {
const result = store.getState().datatypeActions.create({ name: 'matrix', derivation: 'structure' })
expect(result.ok).toBe(false)
expect(result.message).toBe('"matrix" is a function block in the oscat-basic library')
expect(store.getState().project.data.dataTypes).toHaveLength(0)
})

it('names a library function as a function', () => {
const result = store.getState().pouActions.create({ type: 'function', name: 'Sin', language: 'st' })
expect(result.ok).toBe(false)
expect(result.message).toBe('"Sin" is a function in the iec-std-functions library')
})

it('refuses a global variable list create', () => {
const result = store.getState().globalVariableListActions.create('MATRIX')
expect(result.ok).toBe(false)
expect(result.message).toBe('"MATRIX" is a function block in the oscat-basic library')
})

it('refuses a global variable list whose derived type name a library symbol owns', () => {
const result = store.getState().globalVariableListActions.create('Limits')
expect(result.ok).toBe(false)
expect(result.message).toBe(
'"Limits" needs the type name "Limits_TYPE", which is a function block in the oscat-basic library',
)
})

it('refuses a POU rename onto a library symbol', () => {
seedPou('Pump')
const result = store.getState().pouActions.rename('Pump', 'MATRIX')
expect(result.ok).toBe(false)
expect(result.message).toBe('"MATRIX" is a function block in the oscat-basic library')
expect(store.getState().project.data.pous[0].name).toBe('Pump')
})

it('refuses a data type rename onto a library symbol', async () => {
seedDatatype('Motor')
const result = await store.getState().datatypeActions.rename('Motor', 'MATRIX')
expect(result.ok).toBe(false)
expect(result.message).toBe('"MATRIX" is a function block in the oscat-basic library')
expect(store.getState().project.data.dataTypes[0].name).toBe('Motor')
})

it('refuses a global variable list rename onto a library symbol', () => {
seedList('GVL')
const result = store.getState().globalVariableListActions.rename('GVL', 'MATRIX')
expect(result.ok).toBe(false)
expect(result.message).toBe('"MATRIX" is a function block in the oscat-basic library')
})

it('refuses a POU duplicate onto a library symbol', () => {
seedPou('Pump')
const result = store.getState().pouActions.duplicate('Pump', 'MATRIX')
expect(result.ok).toBe(false)
expect(result.message).toBe('"MATRIX" is a function block in the oscat-basic library')
expect(store.getState().project.data.pous).toHaveLength(1)
})

it('refuses a data type duplicate onto a library symbol', () => {
seedDatatype('Motor')
const result = store.getState().datatypeActions.duplicate('Motor', 'MATRIX')
expect(result.ok).toBe(false)
expect(result.message).toBe('"MATRIX" is a function block in the oscat-basic library')
expect(store.getState().project.data.dataTypes).toHaveLength(1)
})

it('refuses a global variable list duplicate onto a library symbol', () => {
seedList('GVL')
const result = store.getState().globalVariableListActions.duplicate('GVL', 'MATRIX')
expect(result.ok).toBe(false)
expect(result.message).toBe('"MATRIX" is a function block in the oscat-basic library')
})

it('allows a name no library symbol owns', () => {
expect(store.getState().pouActions.create({ type: 'program', name: 'Matrices', language: 'st' })).toEqual({
ok: true,
})
})

/**
* The gate is entry-point only: a project saved before it existed still opens,
* and the offending element can still be renamed out of the collision.
*/
it('still opens a project that already carries a colliding name', () => {
const projectData: PLCProjectData = {
dataTypes: [],
pous: [
{
name: 'MATRIX',
pouType: 'program',
interface: { variables: [] },
body: { language: 'st', value: '' },
documentation: '',
},
],
configurations: { resource: { tasks: [], instances: [], globalVariables: [] } },
}
store.getState().sharedWorkspaceActions.handleOpenProjectResponse({
meta: { name: 'TestProject', type: 'plc-project', path: '/test/path' },
projectData,
})

expect(store.getState().project.data.pous.map((pou) => pou.name)).toEqual(['MATRIX'])
expect(store.getState().pouActions.rename('MATRIX', 'Matrices')).toEqual({ ok: true })
})
})
})

// =========================================================================
Expand Down
35 changes: 34 additions & 1 deletion src/frontend/store/slices/shared/slice.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { produce } from 'immer'
import { StateCreator } from 'zustand'

import type { LibraryPouType } from '../../../../middleware/shared/ports/library-types'
import type { PLCRemoteDevice } from '../../../../middleware/shared/ports/types'
import { isValidIecIdentifier } from '../../../../middleware/shared/utils/ethercat'
import { findAllReferencesToDataType } from '../../../utils/data-type-references'
Expand Down Expand Up @@ -156,6 +157,28 @@
}
}

const LIBRARY_SYMBOL_KIND: Record<LibraryPouType, string> = {
function: 'function',
'function-block': 'function block',
}

/**
* The library symbol, if any, that already owns `name`.
*
* Library functions and function blocks are declared in the same generated namespace as
* the project's own elements, and the bundled archives are in every build regardless of
* the project's `libraries` list — so no setting makes such a name safe. The whole
* installed pool counts, not just the bundled set: enabling a library later must not
* turn a project that compiles into one that does not.
*/
function librarySymbolOwning(state: SharedRootState, name: string): { library: string; kind: string } | null {
for (const library of state.libraries.system) {
const symbol = library.pous.find((pou) => nameMatches(pou.name, name))
if (symbol) return { library: library.name, kind: LIBRARY_SYMBOL_KIND[symbol.type] }
}
return null
}

type NamedElementKind = 'pou' | 'data-type' | 'global-variable-list'

const SAME_KIND_TAKEN: Record<NamedElementKind, string> = {
Expand All @@ -170,7 +193,8 @@
* POUs, data types and Global Variable Lists share ONE identifier namespace — IEC gives
* types and variables the same one — and a list occupies TWO symbols in it: the instance
* keeps the user's name, the struct backing it takes `<name>_TYPE`. Checking each
* collection only against itself covered a third of a rule with three parts.
* collection only against itself covered a third of a rule with three parts. Library
* functions and function blocks occupy that same namespace — see `librarySymbolOwning`.
*
* The workspace makes a collision worse than the duplicate symbol the compiler would
* report: `files[name]`, tabs, editor models and `undoRedo[name]` are keyed by raw
Expand Down Expand Up @@ -225,6 +249,11 @@
return `"${name}" is the type name of global variable list "${listOwningTheName.name}"`
}

const librarySymbol = librarySymbolOwning(state, name)
if (librarySymbol) {
return `"${name}" is a ${librarySymbol.kind} in the ${librarySymbol.library} library`
}

if (kind !== 'global-variable-list') return null

const derived = globalVariableListTypeName(name)
Expand All @@ -247,6 +276,10 @@
if (!collidesWithUnparsedDataTypeFile(state, derived).ok) {
return `"${name}" needs the type name "${derived}", which a data type file already uses`
}
const derivedLibrarySymbol = librarySymbolOwning(state, derived)
if (derivedLibrarySymbol) {
return `"${name}" needs the type name "${derived}", which is a ${derivedLibrarySymbol.kind} in the ${derivedLibrarySymbol.library} library`
}
return null
}

Expand Down
Loading