From b4c8ca51d211429fb7b9901765769781764127e4 Mon Sep 17 00:00:00 2001 From: Daniel Coutinho <60111446+dcoutinho1328@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:01:21 -0300 Subject: [PATCH 1/4] feat(plcopen): port PLCopen XML import parser and wire import/export UI (NODE-111, NODE-112) - Port the PLCopen XML->project parser (textual POUs, dataTypes, task/instance config, FBD and LD graphical bodies) from autonomy-node's git history into the shared frontend/utils/PLC/xml-parser/ tree, retargeted to this app's native xyflow-based project model. - Wire "Export as PLCopen XML" (previously a disabled stub) and a new "Import PLCopen XML" menu item, backed by new ProjectPort methods (pickPlcopenImportFile, exportPlcopenFile) and a confirm-overwrite modal. - Editor adapter: implement the two new port methods via Electron IPC (native open/save file dialogs filtered to .xml). - Fix a pre-existing export bug where an unnamed ladder variable node was dropped from generated XML while a still referenced it. - Exclude three new shared test files from this repo's Jest run (testPathIgnorePatterns) that rely on vi.mock with a relative module path, a pre-existing Jest/vi-shim limitation already worked around for notify-no-write-permission.test.ts; these run correctly under web's Vitest. Co-Authored-By: Claude Opus 4.6 (1M context) --- jest.config.json | 8 +- src/__architecture__/validate.ts | 7 + .../utils/__tests__/path-picker.test.ts | 132 ++++ src/backend/editor/utils/path-picker.ts | 68 ++- .../_molecules/menu-bar/menus/file.tsx | 16 +- .../confirm-plcopen-import-modal.test.tsx | 63 ++ .../modals/confirm-plcopen-import-modal.tsx | 70 +++ .../components/_templates/app-layout.tsx | 4 + src/frontend/locales/en/menu.json | 1 + .../services/__tests__/export-actions.test.ts | 175 ++++++ .../services/__tests__/import-actions.test.ts | 143 +++++ src/frontend/services/export-actions.ts | 137 +++++ src/frontend/services/import-actions.ts | 65 ++ src/frontend/store/slices/modal/types.ts | 4 + .../PLC/build-plcopen-project-response.ts | 40 ++ .../language/__tests__/ladder-xml.test.ts | 5 +- .../old-editor/language/ladder-xml.ts | 4 +- .../__tests__/data-type-xml.test.ts | 82 +++ .../__tests__/instances-xml.test.ts | 50 ++ .../__tests__/parse-plcopen-xml.test.ts | 457 ++++++++++++++ .../__tests__/parse-xml-document.test.ts | 19 + .../PLC/xml-parser/__tests__/pou-xml.test.ts | 144 +++++ .../PLC/xml-parser/__tests__/type-xml.test.ts | 76 +++ .../xml-parser/__tests__/variable-xml.test.ts | 59 ++ .../PLC/xml-parser/__tests__/xml-node.test.ts | 41 ++ .../utils/PLC/xml-parser/data-type-xml.ts | 57 ++ src/frontend/utils/PLC/xml-parser/index.ts | 52 ++ .../utils/PLC/xml-parser/instances-xml.ts | 47 ++ .../language/__tests__/fbd-xml.test.ts | 241 ++++++++ .../language/__tests__/geometry.test.ts | 42 ++ .../language/__tests__/ladder-xml.test.ts | 304 +++++++++ .../utils/PLC/xml-parser/language/fbd-xml.ts | 398 ++++++++++++ .../utils/PLC/xml-parser/language/geometry.ts | 53 ++ .../PLC/xml-parser/language/ladder-xml.ts | 578 ++++++++++++++++++ .../PLC/xml-parser/parse-xml-document.ts | 34 ++ src/frontend/utils/PLC/xml-parser/pou-xml.ts | 120 ++++ src/frontend/utils/PLC/xml-parser/type-xml.ts | 48 ++ .../utils/PLC/xml-parser/variable-xml.ts | 45 ++ src/frontend/utils/PLC/xml-parser/xml-node.ts | 17 + .../__tests__/iec-types-registry.test.ts | 32 +- src/frontend/utils/iec-types-registry.ts | 24 + src/main/modules/ipc/main.ts | 39 +- src/main/modules/ipc/renderer.ts | 10 + .../editor/__tests__/project-adapter.test.ts | 58 ++ .../adapters/editor/project-adapter.ts | 16 + .../shared/ports/platform-capabilities.ts | 9 +- src/middleware/shared/ports/project-port.ts | 32 + 47 files changed, 4114 insertions(+), 12 deletions(-) create mode 100644 src/backend/editor/utils/__tests__/path-picker.test.ts create mode 100644 src/frontend/components/_organisms/modals/__tests__/confirm-plcopen-import-modal.test.tsx create mode 100644 src/frontend/components/_organisms/modals/confirm-plcopen-import-modal.tsx create mode 100644 src/frontend/services/__tests__/export-actions.test.ts create mode 100644 src/frontend/services/__tests__/import-actions.test.ts create mode 100644 src/frontend/services/export-actions.ts create mode 100644 src/frontend/services/import-actions.ts create mode 100644 src/frontend/utils/PLC/build-plcopen-project-response.ts create mode 100644 src/frontend/utils/PLC/xml-parser/__tests__/data-type-xml.test.ts create mode 100644 src/frontend/utils/PLC/xml-parser/__tests__/instances-xml.test.ts create mode 100644 src/frontend/utils/PLC/xml-parser/__tests__/parse-plcopen-xml.test.ts create mode 100644 src/frontend/utils/PLC/xml-parser/__tests__/parse-xml-document.test.ts create mode 100644 src/frontend/utils/PLC/xml-parser/__tests__/pou-xml.test.ts create mode 100644 src/frontend/utils/PLC/xml-parser/__tests__/type-xml.test.ts create mode 100644 src/frontend/utils/PLC/xml-parser/__tests__/variable-xml.test.ts create mode 100644 src/frontend/utils/PLC/xml-parser/__tests__/xml-node.test.ts create mode 100644 src/frontend/utils/PLC/xml-parser/data-type-xml.ts create mode 100644 src/frontend/utils/PLC/xml-parser/index.ts create mode 100644 src/frontend/utils/PLC/xml-parser/instances-xml.ts create mode 100644 src/frontend/utils/PLC/xml-parser/language/__tests__/fbd-xml.test.ts create mode 100644 src/frontend/utils/PLC/xml-parser/language/__tests__/geometry.test.ts create mode 100644 src/frontend/utils/PLC/xml-parser/language/__tests__/ladder-xml.test.ts create mode 100644 src/frontend/utils/PLC/xml-parser/language/fbd-xml.ts create mode 100644 src/frontend/utils/PLC/xml-parser/language/geometry.ts create mode 100644 src/frontend/utils/PLC/xml-parser/language/ladder-xml.ts create mode 100644 src/frontend/utils/PLC/xml-parser/parse-xml-document.ts create mode 100644 src/frontend/utils/PLC/xml-parser/pou-xml.ts create mode 100644 src/frontend/utils/PLC/xml-parser/type-xml.ts create mode 100644 src/frontend/utils/PLC/xml-parser/variable-xml.ts create mode 100644 src/frontend/utils/PLC/xml-parser/xml-node.ts diff --git a/jest.config.json b/jest.config.json index 482c4ef06..7572446f6 100644 --- a/jest.config.json +++ b/jest.config.json @@ -15,7 +15,13 @@ "url": "http://localhost/" }, "testMatch": ["/src/**/?(*.)+(spec|test).(ts|tsx)", "/src/**/__tests__/**/*.(ts|tsx)"], - "testPathIgnorePatterns": ["/node_modules/", "src/frontend/utils/__tests__/notify-no-write-permission.test.ts"], + "testPathIgnorePatterns": [ + "/node_modules/", + "src/frontend/utils/__tests__/notify-no-write-permission.test.ts", + "src/frontend/services/__tests__/export-actions.test.ts", + "src/frontend/services/__tests__/import-actions.test.ts", + "src/frontend/components/_organisms/modals/__tests__/confirm-plcopen-import-modal.test.tsx" + ], "transformIgnorePatterns": ["node_modules/(?!strucpp)"], "transform": { "\\.(ts|tsx|js|jsx)$": [ diff --git a/src/__architecture__/validate.ts b/src/__architecture__/validate.ts index e498384df..8070dc4b8 100644 --- a/src/__architecture__/validate.ts +++ b/src/__architecture__/validate.ts @@ -279,6 +279,13 @@ const KNOWN_EXCEPTIONS: Record = { 'frontend/store/slices/ladder/utils/index.ts': ['components'], // Ladder slice — needs nodesBuilder + defaultCustomNodesStyles for rung creation 'frontend/store/slices/ladder/slice.ts': ['components'], + // PLCopen export — needs the shared XmlGenerator composing function + // (backend/shared/utils/PLC/xml-generator.ts) to turn the converted + // project data into XML before handing it to the platform port. No + // frontend-reachable layer re-exports this function today; the + // conversion logic itself stays local (mirrors compiler-adapter.ts's + // portToSchemaProjectData) and has no other backend-shared dependency. + 'frontend/services/export-actions.ts': ['backend-shared'], } // --------------------------------------------------------------------------- diff --git a/src/backend/editor/utils/__tests__/path-picker.test.ts b/src/backend/editor/utils/__tests__/path-picker.test.ts new file mode 100644 index 000000000..782975165 --- /dev/null +++ b/src/backend/editor/utils/__tests__/path-picker.test.ts @@ -0,0 +1,132 @@ +/** + * `getPlcopenImportFilePath` / `getPlcopenExportSavePath` — focused tests. + * + * Electron's `dialog` is mocked (no real native dialog in Jest); the + * filesystem is real (per-test temp dir) so read/write failure paths + * are exercised against actual I/O rather than stubbed error shapes. + */ + +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' + +const showOpenDialogMock = jest.fn() +const showSaveDialogMock = jest.fn() + +jest.mock('electron', () => ({ + BrowserWindow: class {}, + dialog: { + showOpenDialog: (...args: unknown[]) => showOpenDialogMock(...args), + showSaveDialog: (...args: unknown[]) => showSaveDialogMock(...args), + }, +})) + +import { getPlcopenExportSavePath, getPlcopenImportFilePath } from '../path-picker' + +describe('getPlcopenImportFilePath', () => { + let dir: string + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'plcopen-import-')) + jest.clearAllMocks() + }) + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + it('reads and returns the selected file content', async () => { + const filePath = join(dir, 'program.xml') + writeFileSync(filePath, '') + showOpenDialogMock.mockResolvedValue({ canceled: false, filePaths: [filePath] }) + + const result = await getPlcopenImportFilePath({} as never) + + expect(showOpenDialogMock).toHaveBeenCalledWith( + {}, + expect.objectContaining({ + title: 'Select a PLCopen XML file to import', + properties: ['openFile'], + filters: [{ name: 'PLCopen XML', extensions: ['xml'] }], + }), + ) + expect(result).toEqual({ success: true, content: '' }) + }) + + it('returns a canceled error when the user dismisses the dialog', async () => { + showOpenDialogMock.mockResolvedValue({ canceled: true, filePaths: [] }) + + const result = await getPlcopenImportFilePath({} as never) + + expect(result).toEqual({ + success: false, + error: { title: 'Operation canceled', description: 'Operation canceled by the user.' }, + }) + }) + + it('returns a read error when the selected file cannot be read', async () => { + const missingPath = join(dir, 'missing.xml') + showOpenDialogMock.mockResolvedValue({ canceled: false, filePaths: [missingPath] }) + + const result = await getPlcopenImportFilePath({} as never) + + expect(result).toEqual({ + success: false, + error: { title: 'Error reading file', description: 'Failed to read the selected PLCopen XML file.' }, + }) + }) +}) + +describe('getPlcopenExportSavePath', () => { + let dir: string + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'plcopen-export-')) + jest.clearAllMocks() + }) + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + it('writes the XML content to the chosen path', async () => { + const filePath = join(dir, 'exported.xml') + showSaveDialogMock.mockResolvedValue({ canceled: false, filePath }) + + const result = await getPlcopenExportSavePath({} as never, 'exported.xml', '') + + expect(showSaveDialogMock).toHaveBeenCalledWith( + {}, + expect.objectContaining({ + title: 'Export PLCopen XML', + defaultPath: 'exported.xml', + filters: [{ name: 'PLCopen XML', extensions: ['xml'] }], + }), + ) + expect(result).toEqual({ success: true }) + expect(readFileSync(filePath, 'utf-8')).toBe('') + }) + + it('returns a canceled error when the user dismisses the dialog', async () => { + showSaveDialogMock.mockResolvedValue({ canceled: true, filePath: undefined }) + + const result = await getPlcopenExportSavePath({} as never, 'exported.xml', '') + + expect(result).toEqual({ + success: false, + error: { title: 'Operation canceled', description: 'Operation canceled by the user.' }, + }) + }) + + it('returns a write error when the target path cannot be written', async () => { + const badPath = join(dir, 'nonexistent-subdir', 'exported.xml') + showSaveDialogMock.mockResolvedValue({ canceled: false, filePath: badPath }) + + const result = await getPlcopenExportSavePath({} as never, 'exported.xml', '') + + expect(result).toEqual({ + success: false, + error: { title: 'Error writing file', description: 'Failed to write the PLCopen XML file.' }, + }) + }) +}) diff --git a/src/backend/editor/utils/path-picker.ts b/src/backend/editor/utils/path-picker.ts index 85dc985f0..fdf87af07 100644 --- a/src/backend/editor/utils/path-picker.ts +++ b/src/backend/editor/utils/path-picker.ts @@ -74,4 +74,70 @@ const getOpenProjectPath = async (serviceManager: GetProjectPathProps) => { } } -export { getOpenProjectPath, getProjectPath } +const getPlcopenImportFilePath = async (serviceManager: GetProjectPathProps) => { + const { canceled, filePaths } = await dialog.showOpenDialog(serviceManager, { + title: 'Select a PLCopen XML file to import', + properties: ['openFile'], + filters: [{ name: 'PLCopen XML', extensions: ['xml'] }], + }) + if (canceled) { + return { + success: false, + error: { + title: 'Operation canceled', + description: 'Operation canceled by the user.', + }, + } + } + + const [filePath] = filePaths + + try { + const content = await promises.readFile(filePath, 'utf-8') + return { success: true, content } + } catch { + return { + success: false, + error: { + title: 'Error reading file', + description: 'Failed to read the selected PLCopen XML file.', + }, + } + } +} + +const getPlcopenExportSavePath = async ( + serviceManager: GetProjectPathProps, + defaultFileName: string, + xml: string, +) => { + const { canceled, filePath } = await dialog.showSaveDialog(serviceManager, { + title: 'Export PLCopen XML', + defaultPath: defaultFileName, + filters: [{ name: 'PLCopen XML', extensions: ['xml'] }], + }) + if (canceled || !filePath) { + return { + success: false, + error: { + title: 'Operation canceled', + description: 'Operation canceled by the user.', + }, + } + } + + try { + await promises.writeFile(filePath, xml, 'utf-8') + return { success: true } + } catch { + return { + success: false, + error: { + title: 'Error writing file', + description: 'Failed to write the PLCopen XML file.', + }, + } + } +} + +export { getOpenProjectPath, getPlcopenExportSavePath, getPlcopenImportFilePath, getProjectPath } diff --git a/src/frontend/components/_molecules/menu-bar/menus/file.tsx b/src/frontend/components/_molecules/menu-bar/menus/file.tsx index 63bc54de1..3eb585307 100644 --- a/src/frontend/components/_molecules/menu-bar/menus/file.tsx +++ b/src/frontend/components/_molecules/menu-bar/menus/file.tsx @@ -4,6 +4,7 @@ import { useEffect } from 'react' import { useCapabilities, useProject } from '../../../../../middleware/shared/providers' import { useHandleRemoveTab } from '../../../../hooks/use-remove-tab' import { i18n } from '../../../../locales/i18n' +import { executeExportPlcopen } from '../../../../services/export-actions' import { executeSaveActiveFile, executeSaveProject } from '../../../../services/save-actions' import { useOpenPLCStore } from '../../../../store' import { MenuClasses } from '../constants' @@ -83,12 +84,19 @@ export const FileMenu = () => { )} - {capabilities.hasProjectExport && ( + {(capabilities.hasProjectExport || capabilities.hasProjectImport) && ( <> - - {i18n.t('menu:file.submenu.exportToPLCOpenXml')} - + {capabilities.hasProjectExport && ( + void executeExportPlcopen(projectPort)}> + {i18n.t('menu:file.submenu.exportToPLCOpenXml')} + + )} + {capabilities.hasProjectImport && ( + openModal('confirm-plcopen-import')}> + {i18n.t('menu:file.submenu.importFromPLCOpenXml')} + + )} )} diff --git a/src/frontend/components/_organisms/modals/__tests__/confirm-plcopen-import-modal.test.tsx b/src/frontend/components/_organisms/modals/__tests__/confirm-plcopen-import-modal.test.tsx new file mode 100644 index 000000000..8cb66ea02 --- /dev/null +++ b/src/frontend/components/_organisms/modals/__tests__/confirm-plcopen-import-modal.test.tsx @@ -0,0 +1,63 @@ +import { fireEvent, render, screen } from '@testing-library/react' + +const mockProjectPort = { id: 'fake-project-port' } +vi.mock('../../../../../middleware/shared/providers', () => ({ + useProject: () => mockProjectPort, +})) + +const mockExecuteImportPlcopen = vi.fn() +vi.mock('../../../../services/import-actions', () => ({ + executeImportPlcopen: (...args: unknown[]) => mockExecuteImportPlcopen(...args), +})) + +const closeModal = vi.fn() +const onOpenChange = vi.fn() +vi.mock('../../../../store', () => ({ + useOpenPLCStore: () => ({ + modalActions: { onOpenChange, closeModal }, + }), +})) + +import { ConfirmPlcopenImportModal } from '../confirm-plcopen-import-modal' + +describe('ConfirmPlcopenImportModal', () => { + beforeEach(() => { + vi.clearAllMocks() + mockExecuteImportPlcopen.mockResolvedValue({ success: true }) + }) + + it('renders the overwrite warning copy when open', () => { + render() + expect(screen.getByText('Import PLCopen XML?')).toBeTruthy() + expect( + screen.getByText('Importing a PLCopen XML file will overwrite the entire currently open project. This cannot be undone.'), + ).toBeTruthy() + }) + + it('renders nothing visible when closed', () => { + render() + expect(screen.queryByText('Import PLCopen XML?')).toBeNull() + }) + + it('calls executeImportPlcopen with the project port and closes the modal on confirm', async () => { + render() + + fireEvent.click(screen.getByText('Import PLCopen XML')) + + // Flush the async handler. + await Promise.resolve() + await Promise.resolve() + + expect(mockExecuteImportPlcopen).toHaveBeenCalledWith(mockProjectPort) + expect(closeModal).toHaveBeenCalledTimes(1) + }) + + it('closes without importing on cancel', () => { + render() + + fireEvent.click(screen.getByText('Cancel')) + + expect(mockExecuteImportPlcopen).not.toHaveBeenCalled() + expect(closeModal).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/frontend/components/_organisms/modals/confirm-plcopen-import-modal.tsx b/src/frontend/components/_organisms/modals/confirm-plcopen-import-modal.tsx new file mode 100644 index 000000000..e1613ebed --- /dev/null +++ b/src/frontend/components/_organisms/modals/confirm-plcopen-import-modal.tsx @@ -0,0 +1,70 @@ +/** + * Confirmation modal for File → "Import PLCopen XML". + * + * Distinct from `confirm-delete-project` in that it carries no data + * payload — it acts on whatever project is currently open (an in-place + * content overwrite), not a specific record picked from a list. + */ + +import { useProject } from '../../../../middleware/shared/providers' +import { WarningIcon } from '../../../assets/icons/interface/Warning' +import { executeImportPlcopen } from '../../../services/import-actions' +import { useOpenPLCStore } from '../../../store' +import { Modal, ModalContent } from '../../_molecules/modal' + +type ConfirmPlcopenImportModalProps = { + isOpen: boolean +} + +const ConfirmPlcopenImportModal = ({ isOpen, ...rest }: ConfirmPlcopenImportModalProps) => { + const projectPort = useProject() + const { + modalActions: { onOpenChange, closeModal }, + } = useOpenPLCStore() + + const handleConfirm = async () => { + await executeImportPlcopen(projectPort) + closeModal() + } + + return ( + { + if (!open) closeModal() + onOpenChange('confirm-plcopen-import', open) + }} + {...rest} + > + +
+ +
+

+ Import PLCopen XML? +

+

+ Importing a PLCopen XML file will overwrite the entire currently open project. This cannot be undone. +

+
+
+ + +
+
+
+
+ ) +} + +export { ConfirmPlcopenImportModal } diff --git a/src/frontend/components/_templates/app-layout.tsx b/src/frontend/components/_templates/app-layout.tsx index d91213b37..1f696ed8f 100644 --- a/src/frontend/components/_templates/app-layout.tsx +++ b/src/frontend/components/_templates/app-layout.tsx @@ -12,6 +12,7 @@ import AboutModal from '../_organisms/about-modal' import { RuntimeCreateUserModal, RuntimeDiscoverDevicesModal, RuntimeLoginModal } from '../_organisms/modals' import { ConfirmDeleteProjectModal } from '../_organisms/modals/confirm-delete-project-modal' import { ConfirmInstallLibrariesModal } from '../_organisms/modals/confirm-install-libraries-modal' +import { ConfirmPlcopenImportModal } from '../_organisms/modals/confirm-plcopen-import-modal' import { DebuggerMessageModal } from '../_organisms/modals/debugger-message-modal' import { ConfirmDeleteElementModal } from '../_organisms/modals/delete-confirmation-modal' import { MissingLibrariesModal } from '../_organisms/modals/missing-libraries-modal' @@ -126,6 +127,9 @@ const AppLayout = ({ children, ...rest }: AppLayoutProps): ReactNode => { {modals?.['confirm-delete-project']?.open === true && ( )} + {modals?.['confirm-plcopen-import']?.open === true && ( + + )} {modals?.['quit-application']?.open === true && ( )} diff --git a/src/frontend/locales/en/menu.json b/src/frontend/locales/en/menu.json index 426289e70..c5304ffc3 100644 --- a/src/frontend/locales/en/menu.json +++ b/src/frontend/locales/en/menu.json @@ -22,6 +22,7 @@ "closeTab": "Close Tab", "closeProject": "Close Project", "exportToPLCOpenXml": "Export to PLCOpen XML", + "importFromPLCOpenXml": "Import PLCopen XML", "exportToCodesysXml": "Export to CODESYS XML", "pageSetup": "Page Setup", "preview": "Preview", diff --git a/src/frontend/services/__tests__/export-actions.test.ts b/src/frontend/services/__tests__/export-actions.test.ts new file mode 100644 index 000000000..9500a5386 --- /dev/null +++ b/src/frontend/services/__tests__/export-actions.test.ts @@ -0,0 +1,175 @@ +/** + * export-actions.ts test file + * + * `executeExportPlcopen` reads `openPLCStoreBase.getState()`, converts the + * flat store project shape into `XmlGenerator`'s schema shape, and calls + * `projectPort.exportPlcopenFile`. All three collaborators are mocked so the + * test exercises only the conversion + orchestration logic in this file. + */ + +import type { ProjectPort } from '../../../middleware/shared/ports/project-port' +import type { PLCProjectData } from '../../../middleware/shared/ports/types' + +const mockXmlGenerator = vi.fn() +vi.mock('../../../backend/shared/utils/PLC/xml-generator', () => ({ + XmlGenerator: (...args: unknown[]) => mockXmlGenerator(...args), +})) + +const mockGetState = vi.fn() +vi.mock('../../store', () => ({ + openPLCStoreBase: { + getState: () => mockGetState(), + }, +})) + +const mockToast = vi.fn() +vi.mock('../../utils/toast', () => ({ + toast: (...args: unknown[]) => mockToast(...args), +})) + +import { executeExportPlcopen } from '../export-actions' + +function makeProjectData(overrides?: Partial): PLCProjectData { + return { + dataTypes: [], + pous: [ + { + name: 'main', + pouType: 'program', + body: { language: 'st', value: 'a := 1;' }, + interface: { variables: [] }, + documentation: '', + }, + ], + configurations: { resource: { tasks: [], instances: [], globalVariables: [] } }, + ...overrides, + } +} + +function makeState(projectData: PLCProjectData, projectName = 'MyProject') { + return { + project: { + meta: { name: projectName, type: 'plc-project' as const, path: 'proj-1' }, + data: projectData, + }, + } +} + +function makeProjectPort(overrides?: Partial): ProjectPort { + return { + exportPlcopenFile: vi.fn().mockResolvedValue({ success: true }), + pickPlcopenImportFile: vi.fn(), + ...overrides, + } as unknown as ProjectPort +} + +beforeEach(() => { + vi.clearAllMocks() + mockGetState.mockReturnValue(makeState(makeProjectData())) +}) + +describe('executeExportPlcopen', () => { + it('converts the flat project data into schema shape and passes it to XmlGenerator', async () => { + mockXmlGenerator.mockReturnValue({ ok: true, message: 'ok', data: '' }) + const projectPort = makeProjectPort() + + const result = await executeExportPlcopen(projectPort) + + expect(result).toEqual({ success: true }) + expect(mockXmlGenerator).toHaveBeenCalledTimes(1) + const [schemaData, dialect] = mockXmlGenerator.mock.calls[0] + expect(dialect).toBe('old-editor') + expect(schemaData.pous).toEqual([ + { + type: 'program', + data: { + language: 'st', + name: 'main', + variables: [], + body: { language: 'st', value: 'a := 1;' }, + documentation: '', + }, + }, + ]) + expect(schemaData.configuration).toEqual({ + resource: { tasks: [], instances: [], globalVariables: [] }, + }) + }) + + it('maps function and function-block POUs to their discriminated schema shapes', async () => { + mockXmlGenerator.mockReturnValue({ ok: true, message: 'ok', data: '' }) + const projectData = makeProjectData({ + pous: [ + { + name: 'AddOne', + pouType: 'function', + body: { language: 'st', value: 'AddOne := IN + 1;' }, + interface: { returnType: 'INT', variables: [] }, + documentation: 'doc', + }, + { + name: 'Counter', + pouType: 'function-block', + body: { language: 'st', value: '' }, + interface: { variables: [] }, + }, + ], + }) + mockGetState.mockReturnValue(makeState(projectData)) + + await executeExportPlcopen(makeProjectPort()) + + const [schemaData] = mockXmlGenerator.mock.calls[0] + expect(schemaData.pous[0]).toMatchObject({ type: 'function', data: { name: 'AddOne', returnType: 'INT' } }) + expect(schemaData.pous[1]).toMatchObject({ type: 'function-block', data: { name: 'Counter' } }) + }) + + it('calls exportPlcopenFile with the project name and generated XML, and toasts success', async () => { + mockXmlGenerator.mockReturnValue({ ok: true, message: 'ok', data: '' }) + mockGetState.mockReturnValue(makeState(makeProjectData(), 'Widgets')) + const exportPlcopenFile = vi.fn().mockResolvedValue({ success: true }) + const projectPort = makeProjectPort({ exportPlcopenFile }) + + const result = await executeExportPlcopen(projectPort) + + expect(result).toEqual({ success: true }) + expect(exportPlcopenFile).toHaveBeenCalledWith('Widgets.xml', '') + expect(mockToast).toHaveBeenCalledWith(expect.objectContaining({ variant: 'default' })) + }) + + it('toasts a failure and returns success:false when XmlGenerator fails', async () => { + mockXmlGenerator.mockReturnValue({ ok: false, message: 'Main POU not found.' }) + const projectPort = makeProjectPort() + + const result = await executeExportPlcopen(projectPort) + + expect(result).toEqual({ success: false }) + expect(projectPort.exportPlcopenFile).not.toHaveBeenCalled() + expect(mockToast).toHaveBeenCalledWith( + expect.objectContaining({ variant: 'fail', description: 'Main POU not found.' }), + ) + }) + + it('toasts a failure and returns success:false when the platform port fails to save the file', async () => { + mockXmlGenerator.mockReturnValue({ ok: true, message: 'ok', data: '' }) + const exportPlcopenFile = vi.fn().mockResolvedValue({ success: false, error: 'disk full' }) + const projectPort = makeProjectPort({ exportPlcopenFile }) + + const result = await executeExportPlcopen(projectPort) + + expect(result).toEqual({ success: false }) + expect(mockToast).toHaveBeenCalledWith(expect.objectContaining({ variant: 'fail', description: 'disk full' })) + }) + + it('catches unexpected exceptions and toasts a generic failure', async () => { + mockXmlGenerator.mockImplementation(() => { + throw new Error('boom') + }) + const projectPort = makeProjectPort() + + const result = await executeExportPlcopen(projectPort) + + expect(result).toEqual({ success: false }) + expect(mockToast).toHaveBeenCalledWith(expect.objectContaining({ variant: 'fail', description: 'boom' })) + }) +}) diff --git a/src/frontend/services/__tests__/import-actions.test.ts b/src/frontend/services/__tests__/import-actions.test.ts new file mode 100644 index 000000000..7f37678f6 --- /dev/null +++ b/src/frontend/services/__tests__/import-actions.test.ts @@ -0,0 +1,143 @@ +/** + * import-actions.ts test file + * + * `executeImportPlcopen` picks a file via the platform port, parses it with + * `parsePlcopenXml`, and hands the result to the store's + * `handleOpenProjectResponse` action. All collaborators are mocked so the + * test exercises only the orchestration logic in this file. + */ + +import type { ProjectPort } from '../../../middleware/shared/ports/project-port' + +const mockParsePlcopenXml = vi.fn() +vi.mock('../../utils/PLC/xml-parser', () => ({ + parsePlcopenXml: (...args: unknown[]) => mockParsePlcopenXml(...args), +})) + +const mockGetState = vi.fn() +vi.mock('../../store', () => ({ + openPLCStoreBase: { + getState: () => mockGetState(), + }, +})) + +const mockToast = vi.fn() +vi.mock('../../utils/toast', () => ({ + toast: (...args: unknown[]) => mockToast(...args), +})) + +import { executeImportPlcopen } from '../import-actions' + +function makeProjectPort(overrides?: Partial): ProjectPort { + return { + pickPlcopenImportFile: vi.fn().mockResolvedValue({ success: true, content: '' }), + exportPlcopenFile: vi.fn(), + ...overrides, + } as unknown as ProjectPort +} + +const handleOpenProjectResponse = vi.fn() + +beforeEach(() => { + vi.clearAllMocks() + mockGetState.mockReturnValue({ + project: { meta: { name: 'Old', type: 'plc-project', path: 'proj-1' } }, + sharedWorkspaceActions: { handleOpenProjectResponse }, + }) + mockParsePlcopenXml.mockReturnValue({ + projectData: { dataTypes: [], pous: [], configurations: { resource: { tasks: [], instances: [], globalVariables: [] } } }, + warnings: [], + projectName: 'Imported', + }) +}) + +describe('executeImportPlcopen', () => { + it('returns success:false silently when the picker is cancelled', async () => { + const projectPort = makeProjectPort({ + pickPlcopenImportFile: vi.fn().mockResolvedValue({ success: false }), + }) + + const result = await executeImportPlcopen(projectPort) + + expect(result).toEqual({ success: false }) + expect(handleOpenProjectResponse).not.toHaveBeenCalled() + expect(mockToast).not.toHaveBeenCalled() + }) + + it('returns success:false silently when the picker succeeds but has no content', async () => { + const projectPort = makeProjectPort({ + pickPlcopenImportFile: vi.fn().mockResolvedValue({ success: true }), + }) + + const result = await executeImportPlcopen(projectPort) + + expect(result).toEqual({ success: false }) + expect(mockToast).not.toHaveBeenCalled() + }) + + it('parses the picked content and overwrites the open project in-place, preserving path', async () => { + const projectPort = makeProjectPort() + + const result = await executeImportPlcopen(projectPort) + + expect(result).toEqual({ success: true }) + expect(mockParsePlcopenXml).toHaveBeenCalledWith('') + expect(handleOpenProjectResponse).toHaveBeenCalledWith({ + meta: { name: 'Imported', type: 'plc-project', path: 'proj-1' }, + projectData: { dataTypes: [], pous: [], configurations: { resource: { tasks: [], instances: [], globalVariables: [] } } }, + warnings: [], + }) + expect(mockToast).toHaveBeenCalledWith(expect.objectContaining({ variant: 'default' })) + }) + + it('falls back to "Imported Project" when the XML carries no project name', async () => { + mockParsePlcopenXml.mockReturnValue({ + projectData: { dataTypes: [], pous: [], configurations: { resource: { tasks: [], instances: [], globalVariables: [] } } }, + warnings: [], + projectName: '', + }) + const projectPort = makeProjectPort() + + await executeImportPlcopen(projectPort) + + expect(handleOpenProjectResponse).toHaveBeenCalledWith( + expect.objectContaining({ meta: expect.objectContaining({ name: 'Imported Project' }) }), + ) + }) + + it('logs warnings to console and shows a warning toast mentioning the count', async () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + mockParsePlcopenXml.mockReturnValue({ + projectData: { dataTypes: [], pous: [], configurations: { resource: { tasks: [], instances: [], globalVariables: [] } } }, + warnings: ['SFC body dropped', 'Unknown dialect element'], + projectName: 'Imported', + }) + const projectPort = makeProjectPort() + + const result = await executeImportPlcopen(projectPort) + + expect(result).toEqual({ success: true }) + expect(consoleWarnSpy).toHaveBeenCalledTimes(2) + expect(consoleWarnSpy).toHaveBeenCalledWith('[PLCopen import] SFC body dropped') + expect(mockToast).toHaveBeenCalledWith( + expect.objectContaining({ variant: 'warn', description: expect.stringContaining('2 warning(s)') }), + ) + + consoleWarnSpy.mockRestore() + }) + + it('catches parse exceptions and toasts a failure without touching the store', async () => { + mockParsePlcopenXml.mockImplementation(() => { + throw new Error('Invalid PLCopen XML: missing root element') + }) + const projectPort = makeProjectPort() + + const result = await executeImportPlcopen(projectPort) + + expect(result).toEqual({ success: false }) + expect(handleOpenProjectResponse).not.toHaveBeenCalled() + expect(mockToast).toHaveBeenCalledWith( + expect.objectContaining({ variant: 'fail', description: 'Invalid PLCopen XML: missing root element' }), + ) + }) +}) diff --git a/src/frontend/services/export-actions.ts b/src/frontend/services/export-actions.ts new file mode 100644 index 000000000..da90d4000 --- /dev/null +++ b/src/frontend/services/export-actions.ts @@ -0,0 +1,137 @@ +/** + * Export the live project as PLCopen XML. + * + * Converts the store's flat port-shape `PLCProjectData` + * (`middleware/shared/ports/types.ts`) into the discriminated-union schema + * shape `XmlGenerator` consumes (`middleware/shared/ports/open-plc-types.ts` + * — nested `{type,data:{...}}` POUs, singular `configuration`), generates the + * XML, and hands it to the platform port for persistence (native save dialog + * on desktop, browser download on web). + * + * The conversion mirrors the web compiler adapter's `portToSchemaProjectData` + * (`middleware/adapters/web/compiler-adapter.ts`) but is kept local and + * stripped of compile-only concerns (`originalCppPous` and other preprocessor + * sidecars) since this is a plain export, not a compile. + */ + +import type { PLCProjectData as SchemaPLCProjectData } from '../../middleware/shared/ports/open-plc-types' +import type { ProjectPort } from '../../middleware/shared/ports/project-port' +import type { PLCProjectData, PouLanguage } from '../../middleware/shared/ports/types' +import { XmlGenerator } from '../../backend/shared/utils/PLC/xml-generator' +import { openPLCStoreBase } from '../store' +import { toast } from '../utils/toast' + +/** + * Convert the store's flat port-shape project data into the nested + * schema shape `XmlGenerator` expects. See file header for context. + * + * The port-shape `PLCBody.language` field carries a broader union + * (upper- and lower-case variants, see `types.ts`) than the schema + * shape expects — at runtime it's always the lowercase `PouLanguage` + * form every other consumer (save/serialize) already assumes, so the + * narrowing cast below is the same invariant the rest of the codebase + * relies on, not a new assumption. + */ +function portToSchemaProjectData(input: PLCProjectData): SchemaPLCProjectData { + const pous = input.pous.map((pou) => { + const variables = pou.interface?.variables ?? [] + const language = pou.body.language as PouLanguage + if (pou.pouType === 'function') { + return { + type: 'function' as const, + data: { + language, + name: pou.name, + returnType: pou.interface?.returnType ?? 'BOOL', + variables, + body: pou.body, + documentation: pou.documentation ?? '', + }, + } + } + if (pou.pouType === 'function-block') { + return { + type: 'function-block' as const, + data: { + language, + name: pou.name, + variables, + body: pou.body, + documentation: pou.documentation ?? '', + }, + } + } + return { + type: 'program' as const, + data: { + language, + name: pou.name, + variables, + body: pou.body, + documentation: pou.documentation ?? '', + }, + } + }) + + return { + pous, + dataTypes: input.dataTypes, + configuration: { + resource: { + tasks: input.configurations.resource.tasks, + instances: input.configurations.resource.instances, + globalVariables: input.configurations.resource.globalVariables, + }, + }, + servers: input.servers ?? [], + remoteDevices: input.remoteDevices ?? [], + } as SchemaPLCProjectData +} + +/** + * Export the currently open project as a PLCopen XML file. + * Equivalent to File → "Export to PLCOpen XML". + */ +export async function executeExportPlcopen(projectPort: ProjectPort): Promise<{ success: boolean }> { + const state = openPLCStoreBase.getState() + + try { + const schemaData = portToSchemaProjectData(state.project.data) + const xmlResult = XmlGenerator(schemaData, 'old-editor') + + if (!xmlResult.ok || !xmlResult.data) { + toast({ + title: 'Error exporting PLCopen XML', + description: xmlResult.message || 'Failed to generate the PLCopen XML.', + variant: 'fail', + }) + return { success: false } + } + + const fileName = `${state.project.meta.name}.xml` + const exportResult = await projectPort.exportPlcopenFile(fileName, xmlResult.data) + + if (!exportResult.success) { + toast({ + title: 'Error exporting PLCopen XML', + description: exportResult.error ?? 'Failed to save the exported file.', + variant: 'fail', + }) + return { success: false } + } + + toast({ + title: 'Project exported', + description: `"${fileName}" was exported successfully.`, + variant: 'default', + }) + return { success: true } + } catch (err) { + toast({ + title: 'Error exporting PLCopen XML', + description: err instanceof Error ? err.message : 'An unexpected error occurred while exporting.', + variant: 'fail', + }) + return { success: false } + } +} diff --git a/src/frontend/services/import-actions.ts b/src/frontend/services/import-actions.ts new file mode 100644 index 000000000..674e124a7 --- /dev/null +++ b/src/frontend/services/import-actions.ts @@ -0,0 +1,65 @@ +/** + * Import a PLCopen XML file, overwriting the currently open project in-place. + * + * This is a content replacement, not a project switch: the existing + * project's `path` (and file-storage identity) is preserved, only the + * in-memory project data is replaced with what the XML parses to. + */ + +import type { OpenProjectResponseData } from '../store/slices/shared/types' +import type { ProjectPort } from '../../middleware/shared/ports/project-port' +import { openPLCStoreBase } from '../store' +import { buildProjectResponseFromPlcopenParse } from '../utils/PLC/build-plcopen-project-response' +import { parsePlcopenXml } from '../utils/PLC/xml-parser' +import { toast } from '../utils/toast' + +/** + * Pick a PLCopen XML file via the platform port, parse it, and overwrite + * the currently open project with the result. Equivalent to File → + * "Import PLCopen XML" (routed through the confirm-overwrite modal). + */ +export async function executeImportPlcopen(projectPort: ProjectPort): Promise<{ success: boolean }> { + const picked = await projectPort.pickPlcopenImportFile() + if (!picked.success || !picked.content) { + // User cancelled the picker (or the platform failed silently) — no + // error toast, this isn't a failure the user needs to be told about. + return { success: false } + } + + const state = openPLCStoreBase.getState() + + try { + const parseResult = parsePlcopenXml(picked.content) + const { warnings } = parseResult + + const data: OpenProjectResponseData = buildProjectResponseFromPlcopenParse(parseResult, state.project.meta.path) + + state.sharedWorkspaceActions.handleOpenProjectResponse(data) + + if (warnings.length > 0) { + for (const warning of warnings) { + console.warn(`[PLCopen import] ${warning}`) + } + toast({ + title: 'Project imported', + description: `Imported with ${warnings.length} warning(s), see console.`, + variant: 'warn', + }) + } else { + toast({ + title: 'Project imported', + description: 'The PLCopen XML file was imported successfully.', + variant: 'default', + }) + } + + return { success: true } + } catch (err) { + toast({ + title: 'Error importing PLCopen XML', + description: err instanceof Error ? err.message : 'Failed to parse the selected file.', + variant: 'fail', + }) + return { success: false } + } +} diff --git a/src/frontend/store/slices/modal/types.ts b/src/frontend/store/slices/modal/types.ts index 92a25e842..84a34b138 100644 --- a/src/frontend/store/slices/modal/types.ts +++ b/src/frontend/store/slices/modal/types.ts @@ -38,6 +38,10 @@ export type ModalTypes = * commit-message override. Available only when the project port * exposes the README slot (web adapter against the Edge API). */ | 'project-readme' + /** Confirm-overwrite gate for the File → "Import PLCopen XML" menu + * item. Acts on whatever project is currently open — no targeted + * data payload (unlike `confirm-delete-project`). */ + | 'confirm-plcopen-import' export type ModalsState = Record diff --git a/src/frontend/utils/PLC/build-plcopen-project-response.ts b/src/frontend/utils/PLC/build-plcopen-project-response.ts new file mode 100644 index 000000000..7826ac122 --- /dev/null +++ b/src/frontend/utils/PLC/build-plcopen-project-response.ts @@ -0,0 +1,40 @@ +/** + * Shared mapping from a `parsePlcopenXml` result to the `{ meta, projectData, + * warnings }` shape both PLCopen-import call sites need: + * - `import-actions.ts`'s `executeImportPlcopen` (interactive File → Import) + * - `project-adapter.ts`'s `openProjectByPath` (pending-import auto-convert) + * + * Pure and framework-agnostic so it can be reached from both the frontend + * service and the middleware adapter without either importing the other. + */ + +import type { ProjectMeta } from '../../../middleware/shared/ports/types' +import type { PlcopenParseResult } from './xml-parser' + +export interface PlcopenProjectResponseData { + meta: ProjectMeta + projectData: PlcopenParseResult['projectData'] + warnings: string[] +} + +/** + * Build the `{ meta, projectData, warnings }` triple from a PLCopen parse + * result. Name precedence: the XML's own `` (when + * non-empty), then `fallbackName`, then the generic `'Imported Project'` + * default — same precedence the interactive import flow already used. + */ +export function buildProjectResponseFromPlcopenParse( + parseResult: PlcopenParseResult, + path: string, + fallbackName?: string, +): PlcopenProjectResponseData { + return { + meta: { + name: parseResult.projectName || fallbackName || 'Imported Project', + type: 'plc-project', + path, + }, + projectData: parseResult.projectData, + warnings: parseResult.warnings, + } +} diff --git a/src/frontend/utils/PLC/xml-generator/old-editor/language/__tests__/ladder-xml.test.ts b/src/frontend/utils/PLC/xml-generator/old-editor/language/__tests__/ladder-xml.test.ts index c3e715a7c..5e218be61 100644 --- a/src/frontend/utils/PLC/xml-generator/old-editor/language/__tests__/ladder-xml.test.ts +++ b/src/frontend/utils/PLC/xml-generator/old-editor/language/__tests__/ladder-xml.test.ts @@ -269,7 +269,7 @@ describe('ladderToXml (old-editor)', () => { expect(result.body.LD.coil[0].variable).toBe('A') }) - it('skips variable nodes with empty name', () => { + it('still emits variable nodes with empty name (E1 fix — connections may already reference their localId)', () => { const rung = makeRung({ nodes: [ makeLeftRail() as unknown as Node, @@ -298,7 +298,8 @@ describe('ladderToXml (old-editor)', () => { ], }) const result = ladderToXml([rung]) - expect(result.body.LD.inVariable).toHaveLength(0) + expect(result.body.LD.inVariable).toHaveLength(1) + expect(result.body.LD.inVariable[0]['@localId']).toBe('30') expect(result.body.LD.outVariable).toHaveLength(0) }) diff --git a/src/frontend/utils/PLC/xml-generator/old-editor/language/ladder-xml.ts b/src/frontend/utils/PLC/xml-generator/old-editor/language/ladder-xml.ts index c20709a65..096a16eae 100644 --- a/src/frontend/utils/PLC/xml-generator/old-editor/language/ladder-xml.ts +++ b/src/frontend/utils/PLC/xml-generator/old-editor/language/ladder-xml.ts @@ -613,7 +613,9 @@ const ladderToXml = (rungs: RungLadderState[]) => { ladderXML.body.LD.block.push(blockToXml(node as BlockNode, rung, offsetY)) break case 'variable': - if ((node as VariableNode).data.variable.name === '') return + // Always emit — even with an empty name. A elsewhere + // in the XML may already reference this node's numericId; skipping + // it here would leave that connection's @refLocalId dangling. if ((node as VariableNode).data.variant === 'input') ladderXML.body.LD.inVariable.push(inVariableToXML(node as VariableNode, offsetY)) if ((node as VariableNode).data.variant === 'output') diff --git a/src/frontend/utils/PLC/xml-parser/__tests__/data-type-xml.test.ts b/src/frontend/utils/PLC/xml-parser/__tests__/data-type-xml.test.ts new file mode 100644 index 000000000..fa85a621a --- /dev/null +++ b/src/frontend/utils/PLC/xml-parser/__tests__/data-type-xml.test.ts @@ -0,0 +1,82 @@ +import { parseDataTypesXml } from '../data-type-xml' + +describe('parseDataTypesXml', () => { + it('parses a structure derivation', () => { + const result = parseDataTypesXml([ + { + '@name': 'MyStruct', + baseType: { + struct: { + variable: [ + { '@name': 'a', type: { BOOL: '' } }, + { '@name': 'b', type: { INT: '' }, initialValue: { simpleValue: { '@value': '5' } } }, + ], + }, + }, + }, + ]) + expect(result).toEqual([ + { + name: 'MyStruct', + derivation: 'structure', + variable: [ + { name: 'a', type: { definition: 'base-type', value: 'BOOL' }, initialValue: undefined }, + { name: 'b', type: { definition: 'base-type', value: 'INT' }, initialValue: { simpleValue: { value: '5' } } }, + ], + }, + ]) + }) + + it('parses an enumerated derivation with an initial value', () => { + const result = parseDataTypesXml({ + '@name': 'MyEnum', + baseType: { enum: { values: { value: [{ '@name': 'RED' }, { '@name': 'GREEN' }] } } }, + initialValue: { simpleValue: { '@value': 'RED' } }, + }) + expect(result).toEqual([ + { + name: 'MyEnum', + derivation: 'enumerated', + initialValue: 'RED', + values: [{ description: 'RED' }, { description: 'GREEN' }], + }, + ]) + }) + + it('parses an enumerated derivation without an initial value', () => { + const result = parseDataTypesXml({ + '@name': 'MyEnum2', + baseType: { enum: { values: { value: [{ '@name': 'A' }] } } }, + }) + expect(result[0].derivation).toBe('enumerated') + expect((result[0] as { initialValue?: string }).initialValue).toBeUndefined() + }) + + it('parses an array derivation', () => { + const result = parseDataTypesXml({ + '@name': 'MyArray', + baseType: { + array: { + dimension: [{ '@lower': '0', '@upper': '9' }], + baseType: { INT: '' }, + }, + }, + initialValue: { simpleValue: { '@value': '0' } }, + }) + expect(result).toEqual([ + { + name: 'MyArray', + derivation: 'array', + baseType: { definition: 'base-type', value: 'INT' }, + initialValue: '0', + dimensions: [{ dimension: '0..9' }], + }, + ]) + }) + + it('throws for an unrecognized derivation', () => { + expect(() => parseDataTypesXml({ '@name': 'Bad', baseType: {} })).toThrow( + 'Unrecognized dataType derivation for "Bad"', + ) + }) +}) diff --git a/src/frontend/utils/PLC/xml-parser/__tests__/instances-xml.test.ts b/src/frontend/utils/PLC/xml-parser/__tests__/instances-xml.test.ts new file mode 100644 index 000000000..db751ce50 --- /dev/null +++ b/src/frontend/utils/PLC/xml-parser/__tests__/instances-xml.test.ts @@ -0,0 +1,50 @@ +import { parseConfigurationXml } from '../instances-xml' + +describe('parseConfigurationXml', () => { + it('parses a Cyclic task with instances', () => { + const result = parseConfigurationXml({ + configurations: { + configuration: { + resource: { + task: [ + { + '@name': 'task0', + '@priority': '0', + '@interval': 'T#20ms', + pouInstance: [{ '@name': 'inst0', '@typeName': 'main' }], + }, + ], + }, + }, + }, + }) + expect(result.resource.tasks).toEqual([{ name: 'task0', triggering: 'Cyclic', interval: 'T#20ms', priority: 0 }]) + expect(result.resource.instances).toEqual([{ name: 'inst0', task: 'task0', program: 'main' }]) + }) + + it('parses an Interrupt task (no @interval) with empty interval', () => { + const result = parseConfigurationXml({ + configurations: { configuration: { resource: { task: { '@name': 'irq', '@priority': '1' } } } }, + }) + expect(result.resource.tasks).toEqual([{ name: 'irq', triggering: 'Interrupt', interval: '', priority: 1 }]) + }) + + it('parses global variables', () => { + const result = parseConfigurationXml({ + configurations: { + configuration: { + resource: {}, + globalVars: { variable: [{ '@name': 'gvar', type: { BOOL: '' } }] }, + }, + }, + }) + expect(result.resource.globalVariables).toEqual([ + { name: 'gvar', class: 'global', type: { definition: 'base-type', value: 'BOOL' }, location: '', initialValue: null, documentation: '' }, + ]) + }) + + it('defaults to empty tasks/instances/globalVariables when absent', () => { + const result = parseConfigurationXml({}) + expect(result).toEqual({ resource: { tasks: [], instances: [], globalVariables: [] } }) + }) +}) diff --git a/src/frontend/utils/PLC/xml-parser/__tests__/parse-plcopen-xml.test.ts b/src/frontend/utils/PLC/xml-parser/__tests__/parse-plcopen-xml.test.ts new file mode 100644 index 000000000..690cc216f --- /dev/null +++ b/src/frontend/utils/PLC/xml-parser/__tests__/parse-plcopen-xml.test.ts @@ -0,0 +1,457 @@ +import { XmlGenerator } from '../../../../../backend/shared/utils/PLC/xml-generator' +import type { PLCProjectData } from '../../../../../middleware/shared/ports/open-plc-types' +import { parsePlcopenXml } from '../index' + +// --------------------------------------------------------------------------- +// Round-trip fixture: one program per language (ST, IL, LD, FBD), one data +// type per derivation, plus a task/instance/global-variable configuration. +// Built directly against the nested `PLCPou` shape `XmlGenerator` consumes +// (middleware/shared/ports/open-plc-types.ts) — the flat shape produced by +// `parsePlcopenXml` is a different (newer) representation, so equivalence is +// asserted field-by-field below rather than via a single deep-equal. +// --------------------------------------------------------------------------- + +const outHandle = { + id: 'output-variable', + type: 'source' as const, + position: 'right' as const, + glbPosition: { x: 80, y: 15 }, + relPosition: { x: 80, y: 15 }, +} +const inHandle = { + id: 'input-variable', + type: 'target' as const, + position: 'left' as const, + glbPosition: { x: 200, y: 15 }, + relPosition: { x: 0, y: 15 }, +} + +const fbdRung = { + comment: '', + selectedNodes: [], + nodes: [ + { + id: 'iv1', + type: 'input-variable', + position: { x: 0, y: 0 }, + width: 80, + height: 30, + data: { + numericId: '1', + executionOrder: 0, + negated: false, + variable: { name: 'X1' }, + handles: [outHandle], + inputHandles: [], + outputHandles: [outHandle], + outputConnector: outHandle, + draggable: true, + selectable: true, + deletable: true, + variant: 'input-variable', + }, + }, + { + id: 'ov1', + type: 'output-variable', + position: { x: 200, y: 0 }, + width: 80, + height: 30, + data: { + numericId: '2', + executionOrder: 1, + negated: false, + variable: { name: 'Y1' }, + handles: [inHandle], + inputHandles: [inHandle], + outputHandles: [], + inputConnector: inHandle, + draggable: true, + selectable: true, + deletable: true, + variant: 'output-variable', + }, + }, + ], + edges: [ + { id: 'e1', source: 'iv1', sourceHandle: 'output-variable', target: 'ov1', targetHandle: 'input-variable', type: 'smoothstep' }, + ], +} + +const railOutHandle = { id: 'left-rail', type: 'source' as const, position: 'right' as const, glbPosition: { x: 20, y: 20 }, relPosition: { x: 20, y: 20 } } +const contactInHandle = { id: 'input', type: 'target' as const, position: 'left' as const, glbPosition: { x: 50, y: 20 }, relPosition: { x: 0, y: 20 } } +const contactOutHandle = { id: 'output', type: 'source' as const, position: 'right' as const, glbPosition: { x: 90, y: 20 }, relPosition: { x: 40, y: 20 } } +const coilInHandle = { id: 'input', type: 'target' as const, position: 'left' as const, glbPosition: { x: 100, y: 20 }, relPosition: { x: 0, y: 20 } } +const coilOutHandle = { id: 'output', type: 'source' as const, position: 'right' as const, glbPosition: { x: 140, y: 20 }, relPosition: { x: 40, y: 20 } } +const railInHandle = { id: 'right-rail', type: 'target' as const, position: 'left' as const, glbPosition: { x: 150, y: 20 }, relPosition: { x: 0, y: 20 } } + +const ladderRung = { + id: 'rung-0', + comment: '', + defaultBounds: [0, 0, 170, 40], + reactFlowViewport: [170, 40], + selectedNodes: [], + nodes: [ + { + id: 'lr1', + type: 'powerRail', + position: { x: 0, y: 0 }, + width: 20, + height: 40, + data: { + numericId: '1', + variable: { name: '' }, + executionOrder: 0, + handles: [railOutHandle], + inputHandles: [], + outputHandles: [railOutHandle], + outputConnector: railOutHandle, + draggable: true, + selectable: true, + deletable: true, + variant: 'left', + }, + }, + { + id: 'c1', + type: 'contact', + position: { x: 50, y: 0 }, + width: 40, + height: 40, + data: { + numericId: '2', + variable: { name: 'X1' }, + executionOrder: 0, + handles: [contactInHandle, contactOutHandle], + inputHandles: [contactInHandle], + outputHandles: [contactOutHandle], + inputConnector: contactInHandle, + outputConnector: contactOutHandle, + draggable: true, + selectable: true, + deletable: true, + variant: 'default', + }, + }, + { + id: 'co1', + type: 'coil', + position: { x: 100, y: 0 }, + width: 40, + height: 40, + data: { + numericId: '3', + variable: { name: 'Y1' }, + executionOrder: 0, + handles: [coilInHandle, coilOutHandle], + inputHandles: [coilInHandle], + outputHandles: [coilOutHandle], + inputConnector: coilInHandle, + outputConnector: coilOutHandle, + draggable: true, + selectable: true, + deletable: true, + variant: 'default', + }, + }, + { + id: 'rr1', + type: 'powerRail', + position: { x: 150, y: 0 }, + width: 20, + height: 40, + data: { + numericId: '4', + variable: { name: '' }, + executionOrder: 0, + handles: [railInHandle], + inputHandles: [railInHandle], + outputHandles: [], + inputConnector: railInHandle, + draggable: true, + selectable: true, + deletable: true, + variant: 'right', + }, + }, + ], + edges: [ + { id: 'e1', source: 'lr1', sourceHandle: 'left-rail', target: 'c1', targetHandle: 'input', type: 'smoothstep' }, + { id: 'e2', source: 'c1', sourceHandle: 'output', target: 'co1', targetHandle: 'input', type: 'smoothstep' }, + { id: 'e3', source: 'co1', sourceHandle: 'output', target: 'rr1', targetHandle: 'right-rail', type: 'smoothstep' }, + ], +} + +function makeFixture(): PLCProjectData { + return { + dataTypes: [ + { name: 'MyStruct', derivation: 'structure', variable: [{ name: 'flag', type: { definition: 'base-type', value: 'BOOL' } }] }, + { + name: 'MyEnum', + derivation: 'enumerated', + initialValue: 'RED', + values: [{ description: 'RED' }, { description: 'GREEN' }], + }, + { + name: 'MyArray', + derivation: 'array', + baseType: { definition: 'base-type', value: 'INT' }, + initialValue: '0', + dimensions: [{ dimension: '0..9' }], + }, + ], + pous: [ + { + type: 'program', + data: { + name: 'mainSt', + language: 'st', + variables: [ + { name: 'a', class: 'input', type: { definition: 'base-type', value: 'BOOL' }, location: '', documentation: '' }, + ], + body: { language: 'st', value: 'a := TRUE;' }, + documentation: 'ST program', + }, + }, + { + type: 'function-block', + data: { + name: 'mainIl', + language: 'il', + variables: [ + { name: 'b', class: 'local', type: { definition: 'base-type', value: 'INT' }, location: '', documentation: '' }, + ], + body: { language: 'il', value: 'LD 1' }, + documentation: '', + }, + }, + { + type: 'program', + data: { + name: 'mainLd', + language: 'ld', + variables: [ + { name: 'X1', class: 'input', type: { definition: 'base-type', value: 'BOOL' }, location: '', documentation: '' }, + { name: 'Y1', class: 'output', type: { definition: 'base-type', value: 'BOOL' }, location: '', documentation: '' }, + ], + body: { language: 'ld', value: { name: 'mainLd', rungs: [ladderRung] } }, + documentation: '', + }, + }, + { + type: 'program', + data: { + name: 'mainFbd', + language: 'fbd', + variables: [ + { name: 'X1', class: 'input', type: { definition: 'base-type', value: 'BOOL' }, location: '', documentation: '' }, + { name: 'Y1', class: 'output', type: { definition: 'base-type', value: 'BOOL' }, location: '', documentation: '' }, + ], + body: { language: 'fbd', value: { name: 'mainFbd', rung: fbdRung } }, + documentation: '', + }, + }, + ], + configuration: { + resource: { + tasks: [{ name: 'task0', triggering: 'Cyclic', interval: 'T#20ms', priority: 0 }], + instances: [{ name: 'inst0', task: 'task0', program: 'mainSt' }], + globalVariables: [ + { name: 'gvar', class: 'global', type: { definition: 'base-type', value: 'BOOL' }, location: '', documentation: '' }, + ], + }, + }, + } as unknown as PLCProjectData +} + +describe('parsePlcopenXml — round trip against XmlGenerator (old-editor)', () => { + const fixture = makeFixture() + const generated = XmlGenerator(fixture, 'old-editor') + const result = parsePlcopenXml(generated.data as string) + + it('generates successfully and produces no parse warnings', () => { + expect(generated.ok).toBe(true) + expect(result.warnings).toEqual([]) + }) + + it('recovers the hardcoded project name the generator always writes', () => { + expect(result.projectName).toBe('Unnamed') + }) + + it('recovers all three data type derivations', () => { + const byName = Object.fromEntries(result.projectData.dataTypes.map((d) => [d.name, d])) + expect(byName.MyStruct).toEqual({ + name: 'MyStruct', + derivation: 'structure', + variable: [{ name: 'flag', type: { definition: 'base-type', value: 'BOOL' }, initialValue: undefined }], + }) + expect(byName.MyEnum).toEqual({ + name: 'MyEnum', + derivation: 'enumerated', + initialValue: 'RED', + values: [{ description: 'RED' }, { description: 'GREEN' }], + }) + expect(byName.MyArray).toEqual({ + name: 'MyArray', + derivation: 'array', + baseType: { definition: 'base-type', value: 'INT' }, + initialValue: '0', + dimensions: [{ dimension: '0..9' }], + }) + }) + + it('recovers the ST program verbatim', () => { + const pou = result.projectData.pous.find((p) => p.name === 'mainSt') + expect(pou?.pouType).toBe('program') + expect(pou?.documentation).toBe('ST program') + expect(pou?.interface?.variables).toEqual([ + { name: 'a', class: 'input', type: { definition: 'base-type', value: 'BOOL' }, location: '', initialValue: null, documentation: '' }, + ]) + expect(pou?.body).toEqual({ language: 'st', value: 'a := TRUE;' }) + }) + + it('recovers the IL function-block verbatim', () => { + const pou = result.projectData.pous.find((p) => p.name === 'mainIl') + expect(pou?.pouType).toBe('function-block') + expect(pou?.body).toEqual({ language: 'il', value: 'LD 1' }) + }) + + it('recovers the LD program rung: power rails, contact, coil, and their wiring', () => { + const pou = result.projectData.pous.find((p) => p.name === 'mainLd') + expect(pou?.body.language).toBe('ld') + const ldBody = pou?.body.value as { name: string; updated: boolean; rungs: Array<{ nodes: unknown[]; edges: unknown[] }> } + expect(ldBody.name).toBe('mainLd') + expect(ldBody.updated).toBe(false) + expect(ldBody.rungs).toHaveLength(1) + const rung = ldBody.rungs[0] + // Node order follows the raw XML's element-type grouping (leftPowerRail, + // rightPowerRail, contact, coil), not rung/visual position. + expect(rung.nodes.map((n) => (n as { id: string }).id).sort()).toEqual( + ['LEFT-POWER-RAIL-1', 'RIGHT-POWER-RAIL-4', 'CONTACT-2', 'COIL-3'].sort(), + ) + expect(rung.edges).toHaveLength(3) + const nodesById = new Map((rung.nodes as Array<{ id: string; data: { variable: { name: string } } }>).map((n) => [n.id, n])) + const contactNode = nodesById.get('CONTACT-2') as { data: { variable: { name: string } } } + const coilNode = nodesById.get('COIL-3') as { data: { variable: { name: string } } } + expect(contactNode.data.variable).toEqual({ name: 'X1' }) + expect(coilNode.data.variable).toEqual({ name: 'Y1' }) + }) + + it('recovers the FBD program rung: input/output variable nodes and their edge', () => { + const pou = result.projectData.pous.find((p) => p.name === 'mainFbd') + expect(pou?.body.language).toBe('fbd') + const fbdBody = pou?.body.value as { + name: string + updated: boolean + rung: { nodes: unknown[]; edges: unknown[] } + } + expect(fbdBody.name).toBe('mainFbd') + expect(fbdBody.updated).toBe(false) + expect(fbdBody.rung.nodes).toHaveLength(2) + expect(fbdBody.rung.edges).toHaveLength(1) + const nodes = fbdBody.rung.nodes as Array<{ id: string; data: { variable: { name: string } } }> + expect(nodes.find((n) => n.id === 'INPUT-VARIABLE-1')?.data.variable).toEqual({ name: 'X1' }) + expect(nodes.find((n) => n.id === 'OUTPUT-VARIABLE-2')?.data.variable).toEqual({ name: 'Y1' }) + }) + + it('recovers the task/instance/global-variable configuration', () => { + const { resource } = result.projectData.configurations + expect(resource.tasks).toEqual([{ name: 'task0', triggering: 'Cyclic', interval: 'T#20ms', priority: 0 }]) + expect(resource.instances).toEqual([{ name: 'inst0', task: 'task0', program: 'mainSt' }]) + expect(resource.globalVariables).toEqual([ + { name: 'gvar', class: 'global', type: { definition: 'base-type', value: 'BOOL' }, location: '', initialValue: null, documentation: '' }, + ]) + }) +}) + +describe('parsePlcopenXml — dialect scope', () => { + const baseXml = (bodyXml: string) => ` + + + + + + + + + ${bodyXml} + + + + + + + + + + +` + + it('produces a warning (does not throw) for an SFC body', () => { + const result = parsePlcopenXml(baseXml('')) + expect(result.projectData.pous).toEqual([]) + expect(result.warnings).toEqual(['POU "unsupported": Sequential Function Chart is not supported by the importer, skipped']) + }) + + it('produces a warning (does not throw) for a body shape outside the old-editor dialect', () => { + // Simulates a codesys-dialect (or otherwise foreign) body: none of ST/IL/LD/FBD/SFC. + const result = parsePlcopenXml(baseXml('')) + expect(result.projectData.pous).toEqual([]) + expect(result.warnings).toEqual(['POU "unsupported": no recognized body language found, skipped']) + }) + + it('recovers the real project name when the XML carries one', () => { + const result = parsePlcopenXml(baseXml('')) + expect(result.projectName).toBe('Test Project') + }) + + it('defaults projectName to "" when has no name attribute', () => { + const xml = `` + const result = parsePlcopenXml(xml) + expect(result.projectName).toBe('') + }) +}) + +describe('parsePlcopenXml — malformed connection reference', () => { + it('produces a warning (does not crash) for a that does not exist', () => { + const xml = ` + + + + + + + + + + + + + + + + + Y1 + + + + + + + + + + + + + +` + + expect(() => parsePlcopenXml(xml)).not.toThrow() + const result = parsePlcopenXml(xml) + const pou = result.projectData.pous.find((p) => p.name === 'dangling') + const fbdBody = pou?.body.value as { rung: { edges: unknown[] } } + expect(fbdBody.rung.edges).toEqual([]) + expect(result.warnings).toEqual(['POU "dangling": FBD connection references unknown localId "doesnotexist", skipped']) + }) +}) diff --git a/src/frontend/utils/PLC/xml-parser/__tests__/parse-xml-document.test.ts b/src/frontend/utils/PLC/xml-parser/__tests__/parse-xml-document.test.ts new file mode 100644 index 000000000..3ed9a0036 --- /dev/null +++ b/src/frontend/utils/PLC/xml-parser/__tests__/parse-xml-document.test.ts @@ -0,0 +1,19 @@ +import { parseXmlDocument } from '../parse-xml-document' + +describe('parseXmlDocument', () => { + it('parses a minimal project document', () => { + const xml = `` + const result = parseXmlDocument(xml) + expect(result).toHaveProperty('contentHeader') + }) + + it('throws when the root element is missing', () => { + expect(() => parseXmlDocument('')).toThrow( + 'Invalid PLCopen XML: missing root element', + ) + }) + + it('throws for empty/garbage input', () => { + expect(() => parseXmlDocument('')).toThrow('Invalid PLCopen XML: missing root element') + }) +}) diff --git a/src/frontend/utils/PLC/xml-parser/__tests__/pou-xml.test.ts b/src/frontend/utils/PLC/xml-parser/__tests__/pou-xml.test.ts new file mode 100644 index 000000000..84d00e0db --- /dev/null +++ b/src/frontend/utils/PLC/xml-parser/__tests__/pou-xml.test.ts @@ -0,0 +1,144 @@ +import { parseInterfaceXml, parsePousXml } from '../pou-xml' + +describe('parseInterfaceXml', () => { + it('returns an empty variables array with no returnType when interface is empty', () => { + expect(parseInterfaceXml({})).toEqual({ variables: [] }) + }) + + it('categorizes variables by group', () => { + const result = parseInterfaceXml({ + inputVars: { variable: [{ '@name': 'a', type: { BOOL: '' } }] }, + outputVars: { variable: [{ '@name': 'b', type: { BOOL: '' } }] }, + inOutVars: { variable: [{ '@name': 'c', type: { BOOL: '' } }] }, + externalVars: { variable: [{ '@name': 'd', type: { BOOL: '' } }] }, + localVars: { variable: [{ '@name': 'e', type: { BOOL: '' } }] }, + tempVars: { variable: [{ '@name': 'f', type: { BOOL: '' } }] }, + }) + expect(result.variables.map((v) => [v.name, v.class])).toEqual([ + ['a', 'input'], + ['b', 'output'], + ['c', 'inOut'], + ['d', 'external'], + ['e', 'local'], + ['f', 'temp'], + ]) + }) + + it('parses a base-type returnType', () => { + const result = parseInterfaceXml({ returnType: { INT: '' } }) + expect(result.returnType).toBe('INT') + }) + + it('parses a derived returnType', () => { + const result = parseInterfaceXml({ returnType: { derived: { '@name': 'MyType' } } }) + expect(result.returnType).toBe('MyType') + }) +}) + +describe('parsePousXml', () => { + it('parses an ST program with documentation', () => { + const { pous, warnings } = parsePousXml([ + { + '@name': 'main', + '@pouType': 'program', + interface: {}, + documentation: { 'xhtml:p': 'A program' }, + body: { ST: { 'xhtml:p': 'x := 1;' } }, + }, + ]) + expect(warnings).toEqual([]) + expect(pous).toEqual([ + { + name: 'main', + pouType: 'program', + interface: { variables: [] }, + body: { language: 'st', value: 'x := 1;' }, + documentation: 'A program', + }, + ]) + }) + + it('parses an IL function-block', () => { + const { pous } = parsePousXml({ + '@name': 'fb1', + '@pouType': 'functionBlock', + interface: {}, + documentation: { 'xhtml:p': ' ' }, + body: { IL: { 'xhtml:p': 'LD 1' } }, + }) + expect(pous[0].pouType).toBe('function-block') + expect(pous[0].body).toEqual({ language: 'il', value: 'LD 1' }) + expect(pous[0].documentation).toBe('') + }) + + it('parses a function with a returnType', () => { + const { pous } = parsePousXml({ + '@name': 'f1', + '@pouType': 'function', + interface: { returnType: { BOOL: '' } }, + documentation: '', + body: { ST: { 'xhtml:p': 'f1 := TRUE;' } }, + }) + expect(pous[0].interface?.returnType).toBe('BOOL') + }) + + it('parses an LD body', () => { + const { pous, warnings } = parsePousXml({ + '@name': 'ld1', + '@pouType': 'program', + interface: {}, + documentation: '', + body: { LD: {} }, + }) + expect(warnings).toEqual([]) + expect(pous[0].body.language).toBe('ld') + expect(pous[0].body.value).toEqual({ name: 'ld1', updated: false, rungs: [] }) + }) + + it('parses an FBD body', () => { + const { pous, warnings } = parsePousXml({ + '@name': 'fbd1', + '@pouType': 'program', + interface: {}, + documentation: '', + body: { FBD: {} }, + }) + expect(warnings).toEqual([]) + expect(pous[0].body.language).toBe('fbd') + expect(pous[0].body.value).toEqual({ + name: 'fbd1', + updated: false, + rung: { comment: '', nodes: [], edges: [], selectedNodes: [] }, + }) + }) + + it('warns and skips an SFC body', () => { + const { pous, warnings } = parsePousXml({ + '@name': 'sfc1', + '@pouType': 'program', + interface: {}, + documentation: '', + body: { SFC: {} }, + }) + expect(pous).toEqual([]) + expect(warnings).toEqual(['POU "sfc1": Sequential Function Chart is not supported by the importer, skipped']) + }) + + it('warns and skips a POU with an unrecognized pouType', () => { + const { pous, warnings } = parsePousXml({ '@name': 'bad1', '@pouType': 'weird', body: {} }) + expect(pous).toEqual([]) + expect(warnings).toEqual(['POU "bad1": unrecognized pouType "weird", skipped']) + }) + + it('warns and skips a POU with no recognized body language', () => { + const { pous, warnings } = parsePousXml({ + '@name': 'nobody', + '@pouType': 'program', + interface: {}, + documentation: '', + body: {}, + }) + expect(pous).toEqual([]) + expect(warnings).toEqual(['POU "nobody": no recognized body language found, skipped']) + }) +}) diff --git a/src/frontend/utils/PLC/xml-parser/__tests__/type-xml.test.ts b/src/frontend/utils/PLC/xml-parser/__tests__/type-xml.test.ts new file mode 100644 index 000000000..1a992c59d --- /dev/null +++ b/src/frontend/utils/PLC/xml-parser/__tests__/type-xml.test.ts @@ -0,0 +1,76 @@ +import { parseBaseTypeLeaf, parseDimensionsXml, parseTypeXml } from '../type-xml' + +describe('parseTypeXml', () => { + it('parses a base-type element (uppercase tag)', () => { + expect(parseTypeXml({ INT: '' })).toEqual({ definition: 'base-type', value: 'INT' }) + }) + + it('parses a base-type element (lowercase string/wstring tag)', () => { + expect(parseTypeXml({ string: '' })).toEqual({ definition: 'base-type', value: 'STRING' }) + }) + + it('falls back to the raw tag when it is not a recognized IEC element', () => { + expect(parseTypeXml({ CustomTag: '' })).toEqual({ definition: 'base-type', value: 'CustomTag' }) + }) + + it('parses a derived element', () => { + expect(parseTypeXml({ derived: { '@name': 'MyType' } })).toEqual({ definition: 'derived', value: 'MyType' }) + }) + + it('parses an array element with a base-type element type', () => { + const result = parseTypeXml({ + array: { + dimension: [{ '@lower': '0', '@upper': '9' }], + baseType: { INT: '' }, + }, + }) + expect(result).toEqual({ + definition: 'array', + value: 'ARRAY[0..9] OF INT', + data: { baseType: { definition: 'base-type', value: 'INT' }, dimensions: [{ dimension: '0..9' }] }, + }) + }) + + it('parses an array element with a derived element type', () => { + const result = parseTypeXml({ + array: { + dimension: [{ '@lower': '0', '@upper': '3' }], + baseType: { derived: { '@name': 'MyStruct' } }, + }, + }) + expect(result.data?.baseType).toEqual({ definition: 'user-data-type', value: 'MyStruct' }) + }) + + it('throws when the type element is empty', () => { + expect(() => parseTypeXml({})).toThrow('Variable type element is empty') + }) +}) + +describe('parseBaseTypeLeaf', () => { + it('parses a derived leaf', () => { + expect(parseBaseTypeLeaf({ derived: { '@name': 'Foo' } })).toEqual({ definition: 'user-data-type', value: 'Foo' }) + }) + + it('parses a base-type leaf', () => { + expect(parseBaseTypeLeaf({ BOOL: '' })).toEqual({ definition: 'base-type', value: 'BOOL' }) + }) + + it('throws when the leaf has no recognizable base type', () => { + expect(() => parseBaseTypeLeaf({})).toThrow('Type element has no recognizable base type') + }) +}) + +describe('parseDimensionsXml', () => { + it('parses a single dimension object (not yet an array)', () => { + expect(parseDimensionsXml({ '@lower': '1', '@upper': '5' })).toEqual([{ dimension: '1..5' }]) + }) + + it('parses multiple dimensions', () => { + expect( + parseDimensionsXml([ + { '@lower': '0', '@upper': '1' }, + { '@lower': '2', '@upper': '3' }, + ]), + ).toEqual([{ dimension: '0..1' }, { dimension: '2..3' }]) + }) +}) diff --git a/src/frontend/utils/PLC/xml-parser/__tests__/variable-xml.test.ts b/src/frontend/utils/PLC/xml-parser/__tests__/variable-xml.test.ts new file mode 100644 index 000000000..4383757c3 --- /dev/null +++ b/src/frontend/utils/PLC/xml-parser/__tests__/variable-xml.test.ts @@ -0,0 +1,59 @@ +import { extractXhtmlText, parseDocumentationXml, parseVariableXml } from '../variable-xml' + +describe('extractXhtmlText', () => { + it('reads a plain string xhtml:p (no attributes)', () => { + expect(extractXhtmlText({ 'xhtml:p': 'hello' })).toBe('hello') + }) + + it('reads the $ text node when xhtml:p has attributes', () => { + expect(extractXhtmlText({ 'xhtml:p': { $: 'hello' } })).toBe('hello') + }) + + it('returns "" when there is no xhtml:p', () => { + expect(extractXhtmlText({})).toBe('') + }) + + it('returns "" when the $ text node is missing/non-string', () => { + expect(extractXhtmlText({ 'xhtml:p': {} })).toBe('') + }) +}) + +describe('parseDocumentationXml', () => { + it('un-placeholders the single-space convention back to ""', () => { + expect(parseDocumentationXml({ 'xhtml:p': ' ' })).toBe('') + }) + + it('passes through real documentation text', () => { + expect(parseDocumentationXml({ 'xhtml:p': 'A comment' })).toBe('A comment') + }) +}) + +describe('parseVariableXml', () => { + it('parses a fully-populated variable', () => { + const result = parseVariableXml( + { + '@name': 'x', + '@address': '%IX0.0', + type: { BOOL: '' }, + initialValue: { simpleValue: { '@value': 'TRUE' } }, + documentation: { 'xhtml:p': 'A var' }, + }, + 'input', + ) + expect(result).toEqual({ + name: 'x', + class: 'input', + type: { definition: 'base-type', value: 'BOOL' }, + location: '%IX0.0', + initialValue: 'TRUE', + documentation: 'A var', + }) + }) + + it('defaults name to "", location to "", initialValue to null when absent', () => { + const result = parseVariableXml({ type: { INT: '' } }, 'local') + expect(result.name).toBe('') + expect(result.location).toBe('') + expect(result.initialValue).toBeNull() + }) +}) diff --git a/src/frontend/utils/PLC/xml-parser/__tests__/xml-node.test.ts b/src/frontend/utils/PLC/xml-parser/__tests__/xml-node.test.ts new file mode 100644 index 000000000..84ed03916 --- /dev/null +++ b/src/frontend/utils/PLC/xml-parser/__tests__/xml-node.test.ts @@ -0,0 +1,41 @@ +import { asArray, asRecord, asString } from '../xml-node' + +describe('asRecord', () => { + it('returns the object when given an object', () => { + expect(asRecord({ a: 1 })).toEqual({ a: 1 }) + }) + + it('returns {} for non-object values', () => { + expect(asRecord('')).toEqual({}) + expect(asRecord(null)).toEqual({}) + expect(asRecord(undefined)).toEqual({}) + expect(asRecord(42)).toEqual({}) + }) +}) + +describe('asArray', () => { + it('returns [] for undefined', () => { + expect(asArray(undefined)).toEqual([]) + }) + + it('wraps a bare value in an array', () => { + expect(asArray({ a: 1 })).toEqual([{ a: 1 }]) + }) + + it('returns the array unchanged when already an array', () => { + expect(asArray([1, 2, 3])).toEqual([1, 2, 3]) + }) +}) + +describe('asString', () => { + it('returns the string when given a string', () => { + expect(asString('hello')).toBe('hello') + }) + + it('returns "" for non-string values', () => { + expect(asString(42)).toBe('') + expect(asString(undefined)).toBe('') + expect(asString(null)).toBe('') + expect(asString({})).toBe('') + }) +}) diff --git a/src/frontend/utils/PLC/xml-parser/data-type-xml.ts b/src/frontend/utils/PLC/xml-parser/data-type-xml.ts new file mode 100644 index 000000000..fcb8b13b1 --- /dev/null +++ b/src/frontend/utils/PLC/xml-parser/data-type-xml.ts @@ -0,0 +1,57 @@ +import type { PLCDataType, PLCStructureVariable } from '../../../../middleware/shared/ports/types' +import { parseBaseTypeLeaf, parseDimensionsXml, parseTypeXml } from './type-xml' +import { asArray, asRecord, asString } from './xml-node' + +function parseSimpleInitialValue(xml: unknown): string | undefined { + const simpleValue = asRecord(asRecord(xml).simpleValue) + const value = simpleValue['@value'] + return typeof value === 'string' ? value : undefined +} + +function parseStructInitialValue(xml: unknown): { simpleValue: { value: string } } | undefined { + const value = parseSimpleInitialValue(xml) + return value === undefined ? undefined : { simpleValue: { value } } +} + +// Reverse of `oldEditorParseDataTypesToXML` (xml-generator/old-editor/data-type-xml.ts). +export function parseDataTypesXml(dataTypeXml: unknown): PLCDataType[] { + return asArray(dataTypeXml).map((entryRaw) => { + const entry = asRecord(entryRaw) + const name = asString(entry['@name']) + const baseType = asRecord(entry.baseType) + + if ('struct' in baseType) { + const structXml = asRecord(baseType.struct) + const variable: PLCStructureVariable[] = asArray(structXml.variable).map((vRaw) => { + const v = asRecord(vRaw) + return { + name: asString(v['@name']), + type: parseTypeXml(v.type), + initialValue: parseStructInitialValue(v.initialValue), + } + }) + return { name, derivation: 'structure', variable } + } + + if ('enum' in baseType) { + const valuesXml = asRecord(asRecord(baseType.enum).values) + const values = asArray(valuesXml.value).map((v) => ({ description: asString(asRecord(v)['@name']) })) + return { name, derivation: 'enumerated', initialValue: parseSimpleInitialValue(entry.initialValue), values } + } + + if ('array' in baseType) { + const arrayXml = asRecord(baseType.array) + const elementBaseType = parseBaseTypeLeaf(arrayXml.baseType) + const dimensions = parseDimensionsXml(arrayXml.dimension) + return { + name, + derivation: 'array', + baseType: elementBaseType, + initialValue: parseSimpleInitialValue(entry.initialValue), + dimensions, + } + } + + throw new Error(`Unrecognized dataType derivation for "${name}"`) + }) +} diff --git a/src/frontend/utils/PLC/xml-parser/index.ts b/src/frontend/utils/PLC/xml-parser/index.ts new file mode 100644 index 000000000..079bc802e --- /dev/null +++ b/src/frontend/utils/PLC/xml-parser/index.ts @@ -0,0 +1,52 @@ +import type { PLCDataType, PLCInstance, PLCPou, PLCTask, PLCVariable } from '../../../../middleware/shared/ports/types' +import { parseDataTypesXml } from './data-type-xml' +import { parseConfigurationXml } from './instances-xml' +import { parseXmlDocument } from './parse-xml-document' +import { parsePousXml } from './pou-xml' +import { asRecord, asString } from './xml-node' + +// Structurally matches `ParsedProjectData['projectData']` +// (backend/shared/utils/parse-project-files.ts) minus the fields PLCopen XML +// has no representation for (servers, remoteDevices, libraryManifest, +// debugVariables) — `libraries` is filled with `[]` since it's a required +// field on that target shape and bundled/canonical libraries are always-on +// regardless of an explicit enablement list. +export interface ParsedPlcopenProjectData { + dataTypes: PLCDataType[] + pous: PLCPou[] + configurations: { + resource: { + tasks: PLCTask[] + instances: PLCInstance[] + globalVariables: PLCVariable[] + } + } + libraries: { name: string; version: string }[] +} + +export interface PlcopenParseResult { + projectData: ParsedPlcopenProjectData + warnings: string[] + // — the generator never sets this to anything + // but the hardcoded 'Unnamed' (it takes no project-name input), so this is + // only ever meaningful for XML from another tool (e.g. u-Create). '' when + // absent; callers decide what placeholder to fall back to. + projectName: string +} + +// Parses PLCopen TC6-0201 XML into the same project-data shape +// `XmlGenerator` consumes — the inverse of that pipeline +// (xml-generator/old-editor/*.ts). Only the `old-editor` dialect shape is +// handled today: SFC bodies and anything the codesys dialect emits surface +// as non-fatal warnings rather than being parsed. +export function parsePlcopenXml(xml: string): PlcopenParseResult { + const project = parseXmlDocument(xml) + const types = asRecord(project.types) + + const dataTypes = parseDataTypesXml(asRecord(types.dataTypes).dataType) + const { pous, warnings } = parsePousXml(asRecord(types.pous).pou) + const configurations = parseConfigurationXml(project.instances) + const projectName = asString(asRecord(project.contentHeader)['@name']) + + return { projectData: { dataTypes, pous, configurations, libraries: [] }, warnings, projectName } +} diff --git a/src/frontend/utils/PLC/xml-parser/instances-xml.ts b/src/frontend/utils/PLC/xml-parser/instances-xml.ts new file mode 100644 index 000000000..131da3e7f --- /dev/null +++ b/src/frontend/utils/PLC/xml-parser/instances-xml.ts @@ -0,0 +1,47 @@ +import type { PLCInstance, PLCTask, PLCVariable } from '../../../../middleware/shared/ports/types' +import { parseVariableXml } from './variable-xml' +import { asArray, asRecord, asString } from './xml-node' + +export interface ParsedConfiguration { + resource: { + tasks: PLCTask[] + instances: PLCInstance[] + globalVariables: PLCVariable[] + } +} + +// Reverse of `oldEditorInstanceToXml` (xml-generator/old-editor/instances-xml.ts). +// An Interrupt task has no `@interval` in the XML (only Cyclic tasks do), so +// its original interval value can't be recovered here — it comes back as ''. +export function parseConfigurationXml(instancesXml: unknown): ParsedConfiguration { + const configuration = asRecord(asRecord(asRecord(instancesXml).configurations).configuration) + const resource = asRecord(configuration.resource) + + const tasks: PLCTask[] = [] + const instances: PLCInstance[] = [] + + for (const taskXmlRaw of asArray(resource.task)) { + const taskXml = asRecord(taskXmlRaw) + const name = asString(taskXml['@name']) + const interval = taskXml['@interval'] + const triggering: 'Cyclic' | 'Interrupt' = typeof interval === 'string' ? 'Cyclic' : 'Interrupt' + + tasks.push({ + name, + triggering, + interval: typeof interval === 'string' ? interval : '', + priority: Number(asString(taskXml['@priority'])), + }) + + for (const poRaw of asArray(taskXml.pouInstance)) { + const po = asRecord(poRaw) + instances.push({ name: asString(po['@name']), task: name, program: asString(po['@typeName']) }) + } + } + + const globalVariables: PLCVariable[] = asArray(asRecord(configuration.globalVars).variable).map((v) => + parseVariableXml(v, 'global'), + ) + + return { resource: { tasks, instances, globalVariables } } +} diff --git a/src/frontend/utils/PLC/xml-parser/language/__tests__/fbd-xml.test.ts b/src/frontend/utils/PLC/xml-parser/language/__tests__/fbd-xml.test.ts new file mode 100644 index 000000000..dac33eb43 --- /dev/null +++ b/src/frontend/utils/PLC/xml-parser/language/__tests__/fbd-xml.test.ts @@ -0,0 +1,241 @@ +import { BlockNode } from '@root/frontend/components/_atoms/graphical-editor/fbd/block' +import type { VariableNode } from '@root/frontend/components/_atoms/graphical-editor/fbd/utils/types' +import type { BlockVariant } from '@root/frontend/components/_atoms/graphical-editor/types/block' + +import { parseFbdXml } from '../fbd-xml' + +describe('parseFbdXml', () => { + it('returns an empty rung for an empty FBD body', () => { + const { body, warnings } = parseFbdXml('empty', {}) + expect(warnings).toEqual([]) + expect(body).toEqual({ + name: 'empty', + updated: false, + rung: { comment: '', nodes: [], edges: [], selectedNodes: [] }, + }) + }) + + it('parses an input-variable node', () => { + const { body } = parseFbdXml('p', { + inVariable: [ + { + '@localId': '1', + '@executionOrderId': '0', + '@width': '80', + '@height': '30', + '@negated': 'false', + position: { '@x': '0', '@y': '0' }, + connectionPointOut: { relPosition: { '@x': '80', '@y': '15' } }, + expression: 'X1', + }, + ], + }) + const node = body.rung.nodes[0] as VariableNode + expect(node.id).toBe('INPUT-VARIABLE-1') + expect(node.type).toBe('input-variable') + expect(node.data.variable).toEqual({ name: 'X1' }) + expect(node.data.negated).toBe(false) + expect(node.data.outputHandles[0].id).toBe('output-variable') + }) + + it('parses an output-variable node and resolves its connection into an edge', () => { + const { body, warnings } = parseFbdXml('p', { + inVariable: [ + { + '@localId': '1', + '@executionOrderId': '0', + '@width': '80', + '@height': '30', + '@negated': 'false', + position: { '@x': '0', '@y': '0' }, + connectionPointOut: { relPosition: { '@x': '80', '@y': '15' } }, + expression: 'X1', + }, + ], + outVariable: [ + { + '@localId': '2', + '@executionOrderId': '1', + '@width': '80', + '@height': '30', + '@negated': 'true', + position: { '@x': '200', '@y': '0' }, + connectionPointIn: { + relPosition: { '@x': '0', '@y': '15' }, + connection: [{ '@refLocalId': '1' }], + }, + expression: 'Y1', + }, + ], + }) + expect(warnings).toEqual([]) + expect(body.rung.nodes).toHaveLength(2) + expect(body.rung.edges).toEqual([ + { + id: 'xy-edge__INPUT-VARIABLE-1output-variable-OUTPUT-VARIABLE-2input-variable', + source: 'INPUT-VARIABLE-1', + sourceHandle: 'output-variable', + target: 'OUTPUT-VARIABLE-2', + targetHandle: 'input-variable', + type: 'smoothstep', + }, + ]) + const outNode = body.rung.nodes[1] + expect(outNode.data.negated).toBe(true) + }) + + it('parses a block with a function-block instance name and deduped input handles', () => { + const { body } = parseFbdXml('p', { + block: [ + { + '@localId': '3', + '@typeName': 'TON', + '@instanceName': 'ton1', + '@executionOrderId': '2', + '@width': '100', + '@height': '60', + position: { '@x': '50', '@y': '50' }, + inputVariables: { + variable: [ + { + '@formalParameter': 'IN', + connectionPointIn: { relPosition: { '@x': '0', '@y': '10' }, connection: [{ '@refLocalId': '1' }] }, + }, + { + // Same formalParameter, second incoming edge — must be deduped to one handle. + '@formalParameter': 'IN', + connectionPointIn: { relPosition: { '@x': '0', '@y': '10' }, connection: [{ '@refLocalId': '2' }] }, + }, + ], + }, + outputVariables: { + variable: [{ '@formalParameter': 'Q', connectionPointOut: { relPosition: { '@x': '100', '@y': '10' } } }], + }, + }, + ], + }) + const node = body.rung.nodes[0] as BlockNode + expect(node.id).toBe('BLOCK-3') + expect(node.data.inputHandles).toHaveLength(1) + expect(node.data.variable).toEqual({ name: 'ton1' }) + expect(node.data.variant.type).toBe('function-block') + }) + + it('parses a block that is a plain function call (no @instanceName)', () => { + const { body } = parseFbdXml('p', { + block: [ + { + '@localId': '4', + '@typeName': 'ADD', + '@executionOrderId': '0', + '@width': '60', + '@height': '40', + position: { '@x': '0', '@y': '0' }, + inputVariables: '', + outputVariables: '', + }, + ], + }) + const node = body.rung.nodes[0] as BlockNode + expect(node.data.variable).toEqual({ name: 'ADD' }) + expect(node.data.variant.type).toBe('function') + }) + + it('parses a connector/continuation pair', () => { + const { body } = parseFbdXml('p', { + connector: [ + { + '@name': 'c1', + '@localId': '5', + '@width': '40', + '@height': '20', + position: { '@x': '0', '@y': '0' }, + connectionPointIn: { relPosition: { '@x': '0', '@y': '10' }, connection: [{ '@refLocalId': '1' }] }, + }, + ], + continuation: [ + { + '@name': 'c1', + '@localId': '6', + '@width': '40', + '@height': '20', + position: { '@x': '100', '@y': '0' }, + connectionPointOut: { relPosition: { '@x': '40', '@y': '10' } }, + }, + ], + inVariable: [ + { + '@localId': '1', + '@executionOrderId': '0', + '@width': '80', + '@height': '30', + '@negated': 'false', + position: { '@x': '0', '@y': '0' }, + connectionPointOut: { relPosition: { '@x': '80', '@y': '15' } }, + expression: 'X1', + }, + ], + }) + const connector = body.rung.nodes.find((n) => n.type === 'connector') + const continuation = body.rung.nodes.find((n) => n.type === 'continuation') + expect(connector?.data.variable).toEqual({ name: 'c1' }) + expect(continuation?.data.variable).toEqual({ name: 'c1' }) + }) + + it('un-placeholders "No comment provided" back to an empty string', () => { + const { body } = parseFbdXml('p', { + comment: [ + { + '@localId': '7', + '@width': '100', + '@height': '40', + position: { '@x': '0', '@y': '0' }, + content: { 'xhtml:p': 'No comment provided' }, + }, + ], + }) + expect(body.rung.nodes[0].data.content).toBe('') + }) + + it('keeps real comment text', () => { + const { body } = parseFbdXml('p', { + comment: [ + { + '@localId': '8', + '@width': '100', + '@height': '40', + position: { '@x': '0', '@y': '0' }, + content: { 'xhtml:p': 'Real comment' }, + }, + ], + }) + expect(body.rung.nodes[0].data.content).toBe('Real comment') + }) + + it('warns (non-fatally) about inOutVariable nodes', () => { + const { warnings } = parseFbdXml('p', { inOutVariable: [{}] }) + expect(warnings).toEqual(['POU "p": 1 FBD inOutVariable node(s) are not supported, skipped']) + }) + + it('warns (non-fatally) about a dangling connection reference', () => { + const { body, warnings } = parseFbdXml('p', { + outVariable: [ + { + '@localId': '9', + '@executionOrderId': '0', + '@width': '80', + '@height': '30', + '@negated': 'false', + position: { '@x': '0', '@y': '0' }, + connectionPointIn: { + relPosition: { '@x': '0', '@y': '15' }, + connection: [{ '@refLocalId': 'doesnotexist' }], + }, + expression: 'Y1', + }, + ], + }) + expect(body.rung.edges).toEqual([]) + expect(warnings).toEqual(['POU "p": FBD connection references unknown localId "doesnotexist", skipped']) + }) +}) diff --git a/src/frontend/utils/PLC/xml-parser/language/__tests__/geometry.test.ts b/src/frontend/utils/PLC/xml-parser/language/__tests__/geometry.test.ts new file mode 100644 index 000000000..9a06cbbca --- /dev/null +++ b/src/frontend/utils/PLC/xml-parser/language/__tests__/geometry.test.ts @@ -0,0 +1,42 @@ +import { Position } from '@xyflow/react' + +import { makeHandle, parsePositionXml, toNumber } from '../geometry' + +describe('toNumber', () => { + it('parses a numeric string', () => { + expect(toNumber('42')).toBe(42) + }) + + it('falls back to 0 by default for non-numeric input', () => { + expect(toNumber('not-a-number')).toBe(0) + }) + + it('falls back to a custom fallback value for genuinely non-numeric input', () => { + // Number('') is 0 (finite), not NaN — the fallback only kicks in for a + // non-empty, non-numeric string, matching the reference's own behavior. + expect(toNumber('not-a-number', -1)).toBe(-1) + }) +}) + +describe('parsePositionXml', () => { + it('parses @x/@y attributes', () => { + expect(parsePositionXml({ '@x': '10', '@y': '20' })).toEqual({ x: 10, y: 20 }) + }) + + it('defaults to {x:0,y:0} when absent', () => { + expect(parsePositionXml({})).toEqual({ x: 0, y: 0 }) + }) +}) + +describe('makeHandle', () => { + it('builds a handle with glbPosition = nodePosition + relPosition', () => { + const handle = makeHandle('input', 'target', Position.Left, { x: 100, y: 50 }, { '@x': '5', '@y': '10' }) + expect(handle).toEqual({ + id: 'input', + type: 'target', + position: Position.Left, + relPosition: { x: 5, y: 10 }, + glbPosition: { x: 105, y: 60 }, + }) + }) +}) diff --git a/src/frontend/utils/PLC/xml-parser/language/__tests__/ladder-xml.test.ts b/src/frontend/utils/PLC/xml-parser/language/__tests__/ladder-xml.test.ts new file mode 100644 index 000000000..66c750fd0 --- /dev/null +++ b/src/frontend/utils/PLC/xml-parser/language/__tests__/ladder-xml.test.ts @@ -0,0 +1,304 @@ +import type { BlockNode, BlockVariant } from '@root/frontend/components/_atoms/graphical-editor/ladder/utils/types' + +import { parseLadderXml } from '../ladder-xml' + +describe('parseLadderXml', () => { + it('returns no rungs for an empty LD body', () => { + const { body, warnings } = parseLadderXml('empty', {}) + expect(warnings).toEqual([]) + expect(body).toEqual({ name: 'empty', updated: false, rungs: [] }) + }) + + it('reconstructs a single rung: left rail -> contact -> coil -> right rail', () => { + const { body, warnings } = parseLadderXml('rung1', { + leftPowerRail: [ + { + '@localId': '1', + '@width': '20', + '@height': '40', + position: { '@x': '0', '@y': '0' }, + connectionPointOut: { relPosition: { '@x': '20', '@y': '20' } }, + }, + ], + contact: [ + { + '@localId': '2', + '@negated': 'false', + '@width': '40', + '@height': '40', + position: { '@x': '50', '@y': '0' }, + connectionPointIn: { relPosition: { '@x': '0', '@y': '20' }, connection: [{ '@refLocalId': '1', '@formalParameter': 'left-rail' }] }, + connectionPointOut: { relPosition: { '@x': '40', '@y': '20' } }, + variable: ['X1'], + }, + ], + coil: [ + { + '@localId': '3', + '@negated': 'false', + '@width': '40', + '@height': '40', + position: { '@x': '100', '@y': '0' }, + connectionPointIn: { relPosition: { '@x': '0', '@y': '20' }, connection: [{ '@refLocalId': '2', '@formalParameter': 'output' }] }, + connectionPointOut: { relPosition: { '@x': '40', '@y': '20' } }, + variable: ['Y1'], + }, + ], + rightPowerRail: [ + { + '@localId': '4', + '@width': '20', + '@height': '40', + position: { '@x': '150', '@y': '0' }, + connectionPointIn: { relPosition: { '@x': '0', '@y': '20' }, connection: [{ '@refLocalId': '3', '@formalParameter': 'output' }] }, + }, + ], + }) + + expect(warnings).toEqual([]) + expect(body.rungs).toHaveLength(1) + const rung = body.rungs[0] + // Node order within a rung follows the raw XML's element-type grouping + // (leftPowerRail, rightPowerRail, contact, coil, ...) — see + // parseLadderXml's node-collection loop — not rung/visual position. + expect(rung.nodes.map((n) => n.id)).toEqual([ + 'LEFT-POWER-RAIL-1', + 'RIGHT-POWER-RAIL-4', + 'CONTACT-2', + 'COIL-3', + ]) + // Edge order follows pendingEdges collection order (grouped by the + // consuming node's XML element type), not visual left-to-right order — + // compare as a set of {source,target} pairs instead of an exact sequence. + expect(rung.edges).toHaveLength(3) + expect(rung.edges.map((e) => `${e.source}->${e.target}`).sort()).toEqual( + [ + 'LEFT-POWER-RAIL-1->CONTACT-2', + 'CONTACT-2->COIL-3', + 'COIL-3->RIGHT-POWER-RAIL-4', + ].sort(), + ) + expect(rung.edges.every((e) => e.type === 'smoothstep')).toBe(true) + expect((rung.nodes[2].data as { variable: { name: string } }).variable).toEqual({ name: 'X1' }) + expect((rung.nodes[3].data as { variable: { name: string } }).variable).toEqual({ name: 'Y1' }) + expect(rung.defaultBounds).toEqual([0, 0, 170, 40]) + expect(rung.reactFlowViewport).toEqual([170, 40]) + }) + + it('partitions disconnected nodes into separate rungs', () => { + const { body } = parseLadderXml('tworungs', { + leftPowerRail: [ + { + '@localId': '1', + '@width': '20', + '@height': '40', + position: { '@x': '0', '@y': '0' }, + connectionPointOut: { relPosition: { '@x': '20', '@y': '20' } }, + }, + { + '@localId': '2', + '@width': '20', + '@height': '40', + position: { '@x': '0', '@y': '100' }, + connectionPointOut: { relPosition: { '@x': '20', '@y': '20' } }, + }, + ], + }) + expect(body.rungs).toHaveLength(2) + }) + + it('parses coil variants: negated, rising edge, falling edge, set, reset', () => { + const makeCoil = (localId: string, attrs: Record) => ({ + '@localId': localId, + '@width': '40', + '@height': '40', + position: { '@x': '0', '@y': '0' }, + connectionPointIn: { relPosition: { '@x': '0', '@y': '20' } }, + connectionPointOut: { relPosition: { '@x': '40', '@y': '20' } }, + variable: ['Y'], + ...attrs, + }) + const { body } = parseLadderXml('p', { + coil: [ + makeCoil('1', { '@negated': 'true' }), + makeCoil('2', { '@edge': 'rising' }), + makeCoil('3', { '@edge': 'falling' }), + makeCoil('4', { '@storage': 'set' }), + makeCoil('5', { '@storage': 'reset' }), + makeCoil('6', {}), + ], + }) + const variants = body.rungs.flatMap((r) => r.nodes).map((n) => (n.data as { variant: string }).variant) + expect(variants).toEqual(['negated', 'risingEdge', 'fallingEdge', 'set', 'reset', 'default']) + }) + + it('parses contact variants: negated, rising edge, falling edge, default', () => { + const makeContact = (localId: string, attrs: Record) => ({ + '@localId': localId, + '@width': '40', + '@height': '40', + position: { '@x': '0', '@y': '0' }, + connectionPointIn: { relPosition: { '@x': '0', '@y': '20' } }, + connectionPointOut: { relPosition: { '@x': '40', '@y': '20' } }, + variable: ['X'], + ...attrs, + }) + const { body } = parseLadderXml('p', { + contact: [ + makeContact('1', { '@negated': 'true' }), + makeContact('2', { '@edge': 'rising' }), + makeContact('3', { '@edge': 'falling' }), + makeContact('4', {}), + ], + }) + const variants = body.rungs.flatMap((r) => r.nodes).map((n) => (n.data as { variant: string }).variant) + expect(variants).toEqual(['negated', 'risingEdge', 'fallingEdge', 'default']) + }) + + it('parses a function-block instance and a plain function call', () => { + const { body } = parseLadderXml('p', { + block: [ + { + '@localId': '1', + '@typeName': 'TON', + '@instanceName': 'ton1', + '@executionOrderId': '0', + '@width': '100', + '@height': '60', + position: { '@x': '0', '@y': '0' }, + inputVariables: { + variable: [{ '@formalParameter': 'IN', connectionPointIn: { relPosition: { '@x': '0', '@y': '10' } } }], + }, + outputVariables: { + // Unnamed return pin — formalParameter="" maps to the 'OUT' sentinel handle id. + variable: [{ '@formalParameter': '', connectionPointOut: { relPosition: { '@x': '100', '@y': '10' } } }], + }, + }, + ], + }) + const node = body.rungs[0].nodes[0] as BlockNode + expect(node.data.variable).toEqual({ name: 'ton1' }) + expect(node.data.outputHandles[0].id).toBe('OUT') + expect(node.data.variant.type).toBe('function-block') + }) + + it("resolves a pending edge into a block's non-main input pin", () => { + const { body, warnings } = parseLadderXml('p', { + contact: [ + { + '@localId': '1', + '@width': '40', + '@height': '40', + position: { '@x': '0', '@y': '0' }, + connectionPointIn: { relPosition: { '@x': '0', '@y': '20' } }, + connectionPointOut: { relPosition: { '@x': '40', '@y': '20' } }, + variable: ['X1'], + }, + ], + block: [ + { + '@localId': '2', + '@typeName': 'CTU', + '@executionOrderId': '0', + '@width': '100', + '@height': '60', + position: { '@x': '50', '@y': '0' }, + inputVariables: { + variable: [ + { + '@formalParameter': 'PV', + connectionPointIn: { + relPosition: { '@x': '0', '@y': '30' }, + connection: [{ '@refLocalId': '1', '@formalParameter': 'output' }], + }, + }, + ], + }, + outputVariables: '', + }, + ], + }) + expect(warnings).toEqual([]) + const blockNode = body.rungs[0].nodes.find((n) => n.id === 'BLOCK-2') as BlockNode | undefined + expect(blockNode?.data.inputHandles[0].id).toBe('PV') + expect(body.rungs[0].edges).toContainEqual( + expect.objectContaining({ source: 'CONTACT-1', sourceHandle: 'output', target: 'BLOCK-2', targetHandle: 'PV' }), + ) + }) + + it('parses inVariable/outVariable leaf nodes and resolves the block-fed edge', () => { + const { body, warnings } = parseLadderXml('p', { + block: [ + { + '@localId': '1', + '@typeName': 'ADD', + '@executionOrderId': '0', + '@width': '100', + '@height': '60', + position: { '@x': '0', '@y': '0' }, + inputVariables: '', + outputVariables: { + variable: [{ '@formalParameter': 'OUT', connectionPointOut: { relPosition: { '@x': '100', '@y': '10' } } }], + }, + }, + ], + inVariable: [ + { + '@localId': '2', + '@width': '80', + '@height': '30', + position: { '@x': '0', '@y': '100' }, + connectionPointOut: { relPosition: { '@x': '80', '@y': '15' } }, + expression: 'LIT1', + }, + ], + outVariable: [ + { + '@localId': '3', + '@width': '80', + '@height': '30', + position: { '@x': '200', '@y': '0' }, + connectionPointIn: { + relPosition: { '@x': '0', '@y': '15' }, + connection: [{ '@refLocalId': '1', '@formalParameter': 'OUT' }], + }, + expression: 'RESULT', + }, + ], + }) + expect(warnings).toEqual([]) + // The unconnected inVariable literal forms its own rung (no edge ties it + // to the block/outVariable component) — search across all rungs. + const allNodes = body.rungs.flatMap((r) => r.nodes) + const outVarNode = allNodes.find((n) => n.id === 'OUTPUT-VARIABLE-3') + expect(outVarNode?.data.block).toEqual({ id: '', handleId: 'OUT', variableType: { name: '', class: '', type: { definition: 'base-type', value: '' } } }) + const inVarNode = allNodes.find((n) => n.id === 'INPUT-VARIABLE-2') + expect(inVarNode?.data.variable).toEqual({ name: 'LIT1' }) + }) + + it('warns (non-fatally) about inOutVariable nodes', () => { + const { warnings } = parseLadderXml('p', { inOutVariable: [{}] }) + expect(warnings).toEqual(['POU "p": 1 LD inOutVariable node(s) are not supported, skipped']) + }) + + it('warns (non-fatally) about a dangling connection reference', () => { + const { body, warnings } = parseLadderXml('p', { + coil: [ + { + '@localId': '1', + '@width': '40', + '@height': '40', + position: { '@x': '0', '@y': '0' }, + connectionPointIn: { + relPosition: { '@x': '0', '@y': '20' }, + connection: [{ '@refLocalId': 'doesnotexist', '@formalParameter': 'left-rail' }], + }, + connectionPointOut: { relPosition: { '@x': '40', '@y': '20' } }, + variable: ['Y'], + }, + ], + }) + expect(body.rungs[0].edges).toEqual([]) + expect(warnings).toEqual(['POU "p": LD connection references unknown localId "doesnotexist", skipped']) + }) +}) diff --git a/src/frontend/utils/PLC/xml-parser/language/fbd-xml.ts b/src/frontend/utils/PLC/xml-parser/language/fbd-xml.ts new file mode 100644 index 000000000..028652536 --- /dev/null +++ b/src/frontend/utils/PLC/xml-parser/language/fbd-xml.ts @@ -0,0 +1,398 @@ +import { BlockNode } from '@root/frontend/components/_atoms/graphical-editor/fbd/block' +import { + CommentNode, + ConnectionNode, + VariableNode, +} from '@root/frontend/components/_atoms/graphical-editor/fbd/utils/types' +import { BlockVariant } from '@root/frontend/components/_atoms/graphical-editor/types/block' +import { FBDFlowType } from '@root/frontend/store/slices' +import { Edge, Position } from '@xyflow/react' + +import { extractXhtmlText } from '../variable-xml' +import { asArray, asRecord, asString } from '../xml-node' +import { makeHandle, parsePositionXml, toNumber } from './geometry' + +type FbdNode = BlockNode | CommentNode | ConnectionNode | VariableNode + +// Reverse of xml-generator/old-editor/language/fbd-xml.ts. Greenfield (no +// PLCopen import reference existed anywhere before this) — reconstructed +// purely from reading that generator and its zod schema. +// +// Leaf (non-block) FBD nodes have exactly one handle each, and the XML never +// names it directly (no @formalParameter on a leaf node's own element — only +// on a 's reference to a *block's* pin). The sentinel handle ids +// below are confirmed for input-variable/output-variable (an edge's +// sourceHandle into a block is literally "output-variable" when its source +// is an input-variable node); applied by analogy to connector (sink, like +// output-variable) and continuation (source, like input-variable) since +// neither has been directly observed — flagged as an unverified assumption, +// consistent with this parser's provisional old-editor-dialect scope (see +// xml-parser/index.ts). +const LEAF_INPUT_HANDLE_ID = 'input-variable' +const LEAF_OUTPUT_HANDLE_ID = 'output-variable' + +// A block's (or output-variable's/connector's) may reference a +// node that appears later in the XML, so all nodes are built first and +// edges are resolved in a second pass against this pending list. +interface PendingEdge { + targetNumericId: string + targetHandle: string + sourceRefLocalId: string + sourceFormalParameter?: string +} + +function parseConnectionXml(connXml: unknown, targetNumericId: string, targetHandle: string): PendingEdge { + const conn = asRecord(connXml) + const formalParameter = conn['@formalParameter'] + return { + targetNumericId, + targetHandle, + sourceRefLocalId: asString(conn['@refLocalId']), + sourceFormalParameter: typeof formalParameter === 'string' ? formalParameter : undefined, + } +} + +// Reverse of blockToXml. Each +// entry becomes one input handle "X" (deduped — the generator can emit +// several sibling entries sharing the same @formalParameter when +// that pin has multiple incoming edges, rather than one with a +// multi-item array) plus one pending edge per +// child. A pin with zero incoming edges has no element at all, so +// it can't be recovered here — this parser's block only has the handles the +// XML actually mentions. +function parseBlockXml(entry: Record): { node: BlockNode; pendingEdges: PendingEdge[] } { + const numericId = asString(entry['@localId']) + const position = parsePositionXml(entry.position) + const instanceName = entry['@instanceName'] + const isFunctionBlock = typeof instanceName === 'string' + const typeName = asString(entry['@typeName']) + + const inputHandles: BlockNode['data']['inputHandles'] = [] + const seenInputHandleIds = new Set() + const pendingEdges: PendingEdge[] = [] + + for (const varRaw of asArray(asRecord(entry.inputVariables).variable)) { + const v = asRecord(varRaw) + const formalParameter = asString(v['@formalParameter']) + const connIn = asRecord(v.connectionPointIn) + + if (!seenInputHandleIds.has(formalParameter)) { + seenInputHandleIds.add(formalParameter) + inputHandles.push(makeHandle(formalParameter, 'target', Position.Left, position, connIn.relPosition)) + } + for (const connRaw of asArray(connIn.connection)) { + pendingEdges.push(parseConnectionXml(connRaw, numericId, formalParameter)) + } + } + + const outputHandles: BlockNode['data']['outputHandles'] = asArray( + asRecord(entry.outputVariables).variable, + ).map((varRaw) => { + const v = asRecord(varRaw) + const formalParameter = asString(v['@formalParameter']) + const connOut = asRecord(v.connectionPointOut) + return makeHandle(formalParameter, 'source', Position.Right, position, connOut.relPosition) + }) + + // Only a function-block instance carries a name of its own (@instanceName); + // a plain function call has none in the XML (only @typeName, the callee's + // name). `variable.name` is required by the domain type even so — fall + // back to typeName as an honest, non-empty placeholder the generator never + // actually reads back for a non-function-block (see blockToXml's + // `variant.type === 'function-block' ? ... : undefined` guard). + const variableName = isFunctionBlock ? asString(instanceName) : typeName + + const node: BlockNode = { + id: `BLOCK-${numericId}`, + type: 'block', + position, + width: toNumber(entry['@width']), + height: toNumber(entry['@height']), + draggable: true, + selectable: true, + data: { + handles: [...inputHandles, ...outputHandles], + inputHandles, + outputHandles, + // Blocks address pins by name (formalParameter), not a single + // primary connector — never read for a 'block' node by the + // generator's FBD reader, kept `undefined` to satisfy the shared + // BasicNodeData shape. + inputConnector: undefined, + outputConnector: undefined, + numericId, + executionOrder: toNumber(entry['@executionOrderId']), + variable: { name: variableName }, + draggable: true, + selectable: true, + deletable: true, + // Full class/type per pin can't be recovered from the FBD XML alone + // (it only ever names pins, never their IEC variable class/type) — + // an honest, documented gap rather than a guess. `body`/`language` are + // part of `BlockVariant`'s library-POU shape (ports/block-types.ts) + // but never read back by the generator for a placed instance — + // harmless placeholders. + variant: { + name: typeName, + type: isFunctionBlock ? 'function-block' : 'function', + language: 'st', + variables: [], + documentation: '', + body: '', + extensible: false, + }, + executionControl: false, + }, + } + + return { node, pendingEdges } +} + +function parseInVariableXml(entry: Record): VariableNode { + const numericId = asString(entry['@localId']) + const position = parsePositionXml(entry.position) + const outputHandle = makeHandle( + LEAF_OUTPUT_HANDLE_ID, + 'source', + Position.Right, + position, + asRecord(entry.connectionPointOut).relPosition, + ) + + return { + id: `INPUT-VARIABLE-${numericId}`, + type: 'input-variable', + position, + width: toNumber(entry['@width']), + height: toNumber(entry['@height']), + draggable: true, + selectable: true, + data: { + handles: [outputHandle], + inputHandles: [], + outputHandles: [outputHandle], + inputConnector: undefined, + outputConnector: outputHandle, + numericId, + executionOrder: toNumber(entry['@executionOrderId']), + variable: { name: asString(entry.expression) }, + draggable: true, + selectable: true, + deletable: true, + variant: 'input-variable', + negated: asString(entry['@negated']) === 'true', + }, + } +} + +function parseOutVariableXml(entry: Record): { node: VariableNode; pendingEdges: PendingEdge[] } { + const numericId = asString(entry['@localId']) + const position = parsePositionXml(entry.position) + const connIn = asRecord(entry.connectionPointIn) + const inputHandle = makeHandle(LEAF_INPUT_HANDLE_ID, 'target', Position.Left, position, connIn.relPosition) + const pendingEdges = asArray(connIn.connection).map((connRaw) => + parseConnectionXml(connRaw, numericId, LEAF_INPUT_HANDLE_ID), + ) + + const node: VariableNode = { + id: `OUTPUT-VARIABLE-${numericId}`, + type: 'output-variable', + position, + width: toNumber(entry['@width']), + height: toNumber(entry['@height']), + draggable: true, + selectable: true, + data: { + handles: [inputHandle], + inputHandles: [inputHandle], + outputHandles: [], + inputConnector: inputHandle, + outputConnector: undefined, + numericId, + executionOrder: toNumber(entry['@executionOrderId']), + variable: { name: asString(entry.expression) }, + draggable: true, + selectable: true, + deletable: true, + variant: 'output-variable', + negated: asString(entry['@negated']) === 'true', + }, + } + + return { node, pendingEdges } +} + +// connector is a sink (only connectionPointIn, like output-variable) despite +// the name; continuation is the source it (cosmetically) pairs with. Neither +// carries an executionOrderId/negated in the XML — the generator never +// reads or writes them for these two types. +function parseConnectorXml(entry: Record): { node: ConnectionNode; pendingEdges: PendingEdge[] } { + const numericId = asString(entry['@localId']) + const position = parsePositionXml(entry.position) + const connIn = asRecord(entry.connectionPointIn) + const inputHandle = makeHandle(LEAF_INPUT_HANDLE_ID, 'target', Position.Left, position, connIn.relPosition) + const pendingEdges = asArray(connIn.connection).map((connRaw) => + parseConnectionXml(connRaw, numericId, LEAF_INPUT_HANDLE_ID), + ) + + const node: ConnectionNode = { + id: `CONNECTOR-${numericId}`, + type: 'connector', + position, + width: toNumber(entry['@width']), + height: toNumber(entry['@height']), + draggable: true, + selectable: true, + data: { + handles: [inputHandle], + inputHandles: [inputHandle], + outputHandles: [], + inputConnector: inputHandle, + outputConnector: undefined, + numericId, + executionOrder: 0, + variable: { name: asString(entry['@name']) }, + draggable: true, + selectable: true, + deletable: true, + variant: 'connector', + }, + } + + return { node, pendingEdges } +} + +function parseContinuationXml(entry: Record): ConnectionNode { + const numericId = asString(entry['@localId']) + const position = parsePositionXml(entry.position) + const outputHandle = makeHandle( + LEAF_OUTPUT_HANDLE_ID, + 'source', + Position.Right, + position, + asRecord(entry.connectionPointOut).relPosition, + ) + + return { + id: `CONTINUATION-${numericId}`, + type: 'continuation', + position, + width: toNumber(entry['@width']), + height: toNumber(entry['@height']), + draggable: true, + selectable: true, + data: { + handles: [outputHandle], + inputHandles: [], + outputHandles: [outputHandle], + inputConnector: undefined, + outputConnector: outputHandle, + numericId, + executionOrder: 0, + variable: { name: asString(entry['@name']) }, + draggable: true, + selectable: true, + deletable: true, + variant: 'continuation', + }, + } +} + +// The generator writes the literal placeholder 'No comment provided' for an +// empty comment (commentToXml) — reverse it, mirroring parseDocumentationXml's +// ' ' -> '' un-placeholder-ing for the same reason (can't otherwise tell +// "left blank" from "typed the placeholder text"). +function parseCommentXml(entry: Record): CommentNode { + const numericId = asString(entry['@localId']) + const position = parsePositionXml(entry.position) + const content = extractXhtmlText(entry.content) + + return { + id: `COMMENT-${numericId}`, + type: 'comment', + position, + width: toNumber(entry['@width']), + height: toNumber(entry['@height']), + draggable: true, + selectable: true, + data: { + deletable: true, + draggable: true, + selectable: true, + numericId, + content: content === 'No comment provided' ? '' : content, + }, + } +} + +export function parseFbdXml(pouName: string, fbdXml: unknown): { body: FBDFlowType; warnings: string[] } { + const fbd = asRecord(fbdXml) + const warnings: string[] = [] + const nodes: FbdNode[] = [] + const nodeIdByNumericId = new Map() + const pendingEdges: PendingEdge[] = [] + + for (const entry of asArray(fbd.block)) { + const { node, pendingEdges: edges } = parseBlockXml(asRecord(entry)) + nodes.push(node) + nodeIdByNumericId.set(node.data.numericId, node.id) + pendingEdges.push(...edges) + } + for (const entry of asArray(fbd.inVariable)) { + const node = parseInVariableXml(asRecord(entry)) + nodes.push(node) + nodeIdByNumericId.set(node.data.numericId, node.id) + } + for (const entry of asArray(fbd.outVariable)) { + const { node, pendingEdges: edges } = parseOutVariableXml(asRecord(entry)) + nodes.push(node) + nodeIdByNumericId.set(node.data.numericId, node.id) + pendingEdges.push(...edges) + } + for (const entry of asArray(fbd.connector)) { + const { node, pendingEdges: edges } = parseConnectorXml(asRecord(entry)) + nodes.push(node) + nodeIdByNumericId.set(node.data.numericId, node.id) + pendingEdges.push(...edges) + } + for (const entry of asArray(fbd.continuation)) { + const node = parseContinuationXml(asRecord(entry)) + nodes.push(node) + nodeIdByNumericId.set(node.data.numericId, node.id) + } + for (const entry of asArray(fbd.comment)) { + nodes.push(parseCommentXml(asRecord(entry))) + } + + // inOutVariable is a confirmed dead branch in the generator (fbdToXml's + // switch has no case for it, so it's never emitted) — if XML from a + // different tool populates it, surface as a warning rather than guessing + // at semantics with zero forward-generation precedent. + const inOutCount = asArray(fbd.inOutVariable).length + if (inOutCount > 0) { + warnings.push(`POU "${pouName}": ${inOutCount} FBD inOutVariable node(s) are not supported, skipped`) + } + + const edges: Edge[] = [] + for (const pending of pendingEdges) { + const targetNodeId = nodeIdByNumericId.get(pending.targetNumericId) + const sourceNodeId = nodeIdByNumericId.get(pending.sourceRefLocalId) + if (!targetNodeId || !sourceNodeId) { + warnings.push( + `POU "${pouName}": FBD connection references unknown localId "${pending.sourceRefLocalId}", skipped`, + ) + continue + } + const sourceHandle = pending.sourceFormalParameter ?? LEAF_OUTPUT_HANDLE_ID + edges.push({ + id: `xy-edge__${sourceNodeId}${sourceHandle}-${targetNodeId}${pending.targetHandle}`, + source: sourceNodeId, + sourceHandle, + target: targetNodeId, + targetHandle: pending.targetHandle, + type: 'smoothstep', + }) + } + + return { body: { name: pouName, updated: false, rung: { comment: '', nodes, edges, selectedNodes: [] } }, warnings } +} diff --git a/src/frontend/utils/PLC/xml-parser/language/geometry.ts b/src/frontend/utils/PLC/xml-parser/language/geometry.ts new file mode 100644 index 000000000..bd3477df4 --- /dev/null +++ b/src/frontend/utils/PLC/xml-parser/language/geometry.ts @@ -0,0 +1,53 @@ +import { Position } from '@xyflow/react' + +import { asRecord, asString } from '../xml-node' + +export type XyPosition = { x: number; y: number } + +// Shared by every graphical-body parser (fbd-xml.ts, ladder-xml.ts): position/ +// dimension attributes are always numeric strings (parseAttributeValue is off +// project-wide, see parse-xml-document.ts), so every numeric read goes +// through this. +export function toNumber(value: unknown, fallback = 0): number { + const n = Number(asString(value)) + return Number.isFinite(n) ? n : fallback +} + +export function parsePositionXml(xml: unknown): XyPosition { + const rec = asRecord(xml) + return { x: toNumber(rec['@x']), y: toNumber(rec['@y']) } +} + +// Structural shape of `CustomHandleProps` (fbd/handle.tsx, ladder/handle.tsx) +// minus the framework-optional fields — kept local instead of importing +// either component's type so this helper stays usable from both languages. +// `Position` is a real TS enum (not a string-literal union), so callers pass +// `Position.Left`/`Position.Right`, not plain strings. +export interface HandleGeometry { + id: string + type: 'source' | 'target' + position: Position + glbPosition: XyPosition + relPosition: XyPosition +} + +// glbPosition (absolute canvas coordinates) never appears in PLCopen XML — +// only relPosition (offset from the node's own position) does. Reconstructed +// as node.position + relPosition; not necessarily byte-identical to what the +// original editor computed, but internally consistent for a fresh import. +export function makeHandle( + id: string, + kind: 'source' | 'target', + side: Position, + nodePosition: XyPosition, + relPositionXml: unknown, +): HandleGeometry { + const relPosition = parsePositionXml(relPositionXml) + return { + id, + type: kind, + position: side, + relPosition, + glbPosition: { x: nodePosition.x + relPosition.x, y: nodePosition.y + relPosition.y }, + } +} diff --git a/src/frontend/utils/PLC/xml-parser/language/ladder-xml.ts b/src/frontend/utils/PLC/xml-parser/language/ladder-xml.ts new file mode 100644 index 000000000..51cf472be --- /dev/null +++ b/src/frontend/utils/PLC/xml-parser/language/ladder-xml.ts @@ -0,0 +1,578 @@ +import { + BlockNode, + BlockVariant, + CoilNode, + ContactNode, + PowerRailNode, + VariableNode, +} from '@root/frontend/components/_atoms/graphical-editor/ladder/utils/types' +import { LadderFlowType } from '@root/frontend/store/slices' +import { Edge, Position } from '@xyflow/react' + +import { asArray, asRecord, asString } from '../xml-node' +import { makeHandle, parsePositionXml, toNumber } from './geometry' + +type LadderParsedNode = PowerRailNode | ContactNode | CoilNode | BlockNode | VariableNode + +// Reverse of xml-generator/old-editor/language/ladder-xml.ts. Greenfield (no +// PLCopen import reference existed anywhere before this) — reconstructed by +// reading that generator's findConnections/blockToXml/etc. in full. +// +// Handle ids are literal and stable in this dialect (unlike FBD's invented +// sentinels): power rails use "left-rail"/"right-rail", contacts/coils/leaf +// variable nodes use "input"/"output", blocks use their formal parameter +// names — confirmed directly from the generator (leftRailToXML/ +// contactToXML/coilToXml never derive these from anything else). +const RAIL_OUTPUT_HANDLE = 'left-rail' +const RAIL_INPUT_HANDLE = 'right-rail' +const LEAF_INPUT_HANDLE = 'input' +const LEAF_OUTPUT_HANDLE = 'output' + +// A plain function's single unnamed return pin has the domain handle id +// 'OUT', which the generator's findConnections collapses to an empty +// `@formalParameter` string on export (`sourceHandle === 'OUT' ? '' : ...`, +// ladder-xml.ts) — reversed here. `@formalParameter` is otherwise always +// present on a built by findConnections (rightPowerRail/ +// contact/coil/block); it is omitted entirely only on the one bespoke path +// where a block's input pin is wired directly to a named node +// (blockToXml's "connected to an existing variable node" branch) — that +// case has no attribute to read at all, so its source handle defaults to +// the leaf output handle below. +const UNNAMED_FUNCTION_RETURN_HANDLE = 'OUT' + +// A contact's/coil's own `Name` text child shares its +// tag name with the interface/block-pin `` LISTS the shared +// parser config (parse-xml-document.ts) always force-arrays — so it arrives +// here wrapped in a one-item array, not a plain string. Unwrap defensively. +function parseBoundVariableName(value: unknown): string { + // Array.isArray narrows `unknown` to `any[]`, not `unknown[]` — re-widen + // explicitly so the extracted element stays type-safe. + const first: unknown = Array.isArray(value) ? (value as unknown[])[0] : value + return asString(first) +} + +// A block's (or contact/coil/rail's) may reference a node that +// appears later in the XML, so all nodes are built first and edges are +// resolved in a second pass against this pending list. +interface PendingEdge { + targetNumericId: string + targetHandle: string + sourceRefLocalId: string + sourceFormalParameter: string | undefined +} + +function parseConnectionXml(connXml: unknown, targetNumericId: string, targetHandle: string): PendingEdge { + const conn = asRecord(connXml) + const hasFormalParameter = '@formalParameter' in conn + const raw = asString(conn['@formalParameter']) + return { + targetNumericId, + targetHandle, + sourceRefLocalId: asString(conn['@refLocalId']), + sourceFormalParameter: hasFormalParameter ? (raw === '' ? UNNAMED_FUNCTION_RETURN_HANDLE : raw) : undefined, + } +} + +function parseLeftRailXml(entry: Record): PowerRailNode { + const numericId = asString(entry['@localId']) + const position = parsePositionXml(entry.position) + const outputHandle = makeHandle( + RAIL_OUTPUT_HANDLE, + 'source', + Position.Right, + position, + asRecord(entry.connectionPointOut).relPosition, + ) + + return { + id: `LEFT-POWER-RAIL-${numericId}`, + type: 'powerRail', + position, + width: toNumber(entry['@width']), + height: toNumber(entry['@height']), + draggable: true, + selectable: true, + data: { + handles: [outputHandle], + inputHandles: [], + outputHandles: [outputHandle], + inputConnector: undefined, + outputConnector: outputHandle, + numericId, + variable: { name: '' }, + executionOrder: 0, + draggable: true, + selectable: true, + deletable: true, + variant: 'left', + }, + } +} + +function parseRightRailXml(entry: Record): { node: PowerRailNode; pendingEdges: PendingEdge[] } { + const numericId = asString(entry['@localId']) + const position = parsePositionXml(entry.position) + const connIn = asRecord(entry.connectionPointIn) + const inputHandle = makeHandle(RAIL_INPUT_HANDLE, 'target', Position.Left, position, connIn.relPosition) + const pendingEdges = asArray(connIn.connection).map((connRaw) => + parseConnectionXml(connRaw, numericId, RAIL_INPUT_HANDLE), + ) + + const node: PowerRailNode = { + id: `RIGHT-POWER-RAIL-${numericId}`, + type: 'powerRail', + position, + width: toNumber(entry['@width']), + height: toNumber(entry['@height']), + draggable: true, + selectable: true, + data: { + handles: [inputHandle], + inputHandles: [inputHandle], + outputHandles: [], + inputConnector: inputHandle, + outputConnector: undefined, + numericId, + variable: { name: '' }, + executionOrder: 0, + draggable: true, + selectable: true, + deletable: true, + variant: 'right', + }, + } + + return { node, pendingEdges } +} + +// @negated/@edge(/@storage for coils) are independent XML attributes mapped +// onto one mutually-exclusive domain variant enum; the generator only ever +// emits one of them at a time (its own ternary chains enforce that), but +// nothing in the XML shape prevents a foreign document from setting more +// than one — priority storage > negated > edge is an arbitrary, documented +// call for that (currently unseen-in-fixtures) case. +function parseCoilVariant(entry: Record): 'default' | 'negated' | 'risingEdge' | 'fallingEdge' | 'set' | 'reset' { + const storage = entry['@storage'] + if (storage === 'set') return 'set' + if (storage === 'reset') return 'reset' + if (asString(entry['@negated']) === 'true') return 'negated' + if (entry['@edge'] === 'rising') return 'risingEdge' + if (entry['@edge'] === 'falling') return 'fallingEdge' + return 'default' +} + +function parseContactVariant(entry: Record): 'default' | 'negated' | 'risingEdge' | 'fallingEdge' { + if (asString(entry['@negated']) === 'true') return 'negated' + if (entry['@edge'] === 'rising') return 'risingEdge' + if (entry['@edge'] === 'falling') return 'fallingEdge' + return 'default' +} + +function parseContactXml(entry: Record): { node: ContactNode; pendingEdges: PendingEdge[] } { + const numericId = asString(entry['@localId']) + const position = parsePositionXml(entry.position) + const connIn = asRecord(entry.connectionPointIn) + const inputHandle = makeHandle(LEAF_INPUT_HANDLE, 'target', Position.Left, position, connIn.relPosition) + const outputHandle = makeHandle( + LEAF_OUTPUT_HANDLE, + 'source', + Position.Right, + position, + asRecord(entry.connectionPointOut).relPosition, + ) + const pendingEdges = asArray(connIn.connection).map((connRaw) => + parseConnectionXml(connRaw, numericId, LEAF_INPUT_HANDLE), + ) + + const node: ContactNode = { + id: `CONTACT-${numericId}`, + type: 'contact', + position, + width: toNumber(entry['@width']), + height: toNumber(entry['@height']), + draggable: true, + selectable: true, + data: { + handles: [inputHandle, outputHandle], + inputHandles: [inputHandle], + outputHandles: [outputHandle], + inputConnector: inputHandle, + outputConnector: outputHandle, + numericId, + variable: { name: parseBoundVariableName(entry.variable) }, + executionOrder: 0, + draggable: true, + selectable: true, + deletable: true, + variant: parseContactVariant(entry), + }, + } + + return { node, pendingEdges } +} + +function parseCoilXml(entry: Record): { node: CoilNode; pendingEdges: PendingEdge[] } { + const numericId = asString(entry['@localId']) + const position = parsePositionXml(entry.position) + const connIn = asRecord(entry.connectionPointIn) + const inputHandle = makeHandle(LEAF_INPUT_HANDLE, 'target', Position.Left, position, connIn.relPosition) + const outputHandle = makeHandle( + LEAF_OUTPUT_HANDLE, + 'source', + Position.Right, + position, + asRecord(entry.connectionPointOut).relPosition, + ) + const pendingEdges = asArray(connIn.connection).map((connRaw) => + parseConnectionXml(connRaw, numericId, LEAF_INPUT_HANDLE), + ) + + const node: CoilNode = { + id: `COIL-${numericId}`, + type: 'coil', + position, + width: toNumber(entry['@width']), + height: toNumber(entry['@height']), + draggable: true, + selectable: true, + data: { + handles: [inputHandle, outputHandle], + inputHandles: [inputHandle], + outputHandles: [outputHandle], + inputConnector: inputHandle, + outputConnector: outputHandle, + numericId, + variable: { name: parseBoundVariableName(entry.variable) }, + executionOrder: 0, + draggable: true, + selectable: true, + deletable: true, + variant: parseCoilVariant(entry), + }, + } + + return { node, pendingEdges } +} + +// One per declared pin (never duplicated +// per-edge the way FBD's block inputs are — findConnections nests every +// matching inside that single variable's connectionPointIn), +// so — unlike fbd-xml.ts — no formalParameter-grouping/dedup is needed here. +function parseBlockXml(entry: Record): { node: BlockNode; pendingEdges: PendingEdge[] } { + const numericId = asString(entry['@localId']) + const position = parsePositionXml(entry.position) + const instanceName = entry['@instanceName'] + const isFunctionBlock = typeof instanceName === 'string' + const typeName = asString(entry['@typeName']) + + const inputHandles: BlockNode['data']['inputHandles'] = [] + const pendingEdges: PendingEdge[] = [] + + for (const varRaw of asArray(asRecord(entry.inputVariables).variable)) { + const v = asRecord(varRaw) + const formalParameter = asString(v['@formalParameter']) + const connIn = asRecord(v.connectionPointIn) + inputHandles.push(makeHandle(formalParameter, 'target', Position.Left, position, connIn.relPosition)) + for (const connRaw of asArray(connIn.connection)) { + pendingEdges.push(parseConnectionXml(connRaw, numericId, formalParameter)) + } + } + + // A plain function's unnamed return pin is declared here as formalParameter="" + // (see UNNAMED_FUNCTION_RETURN_HANDLE) — translate its own handle id the + // same way other nodes' connections referencing it will expect. + const outputHandles: BlockNode['data']['outputHandles'] = asArray( + asRecord(entry.outputVariables).variable, + ).map((varRaw) => { + const v = asRecord(varRaw) + const raw = asString(v['@formalParameter']) + const handleId = raw === '' ? UNNAMED_FUNCTION_RETURN_HANDLE : raw + const connOut = asRecord(v.connectionPointOut) + return makeHandle(handleId, 'source', Position.Right, position, connOut.relPosition) + }) + + const variableName = isFunctionBlock ? asString(instanceName) : typeName + + const node: BlockNode = { + id: `BLOCK-${numericId}`, + type: 'block', + position, + width: toNumber(entry['@width']), + height: toNumber(entry['@height']), + draggable: true, + selectable: true, + data: { + handles: [...inputHandles, ...outputHandles], + inputHandles, + outputHandles, + inputConnector: inputHandles[0], + outputConnector: outputHandles[0], + numericId, + variable: { name: variableName }, + executionOrder: toNumber(entry['@executionOrderId']), + draggable: true, + selectable: true, + deletable: true, + // Full class/type per pin can't be recovered from the LD XML alone + // (it only ever names pins, never their IEC class/type) — an honest + // documented gap, same as the FBD importer's block variant. + variant: { + name: typeName, + type: isFunctionBlock ? 'function-block' : 'function', + variables: [], + documentation: '', + extensible: false, + }, + executionControl: false, + lockExecutionControl: false, + connectedVariables: [], + }, + } + + return { node, pendingEdges } +} + +function parseInVariableXml(entry: Record): VariableNode { + const numericId = asString(entry['@localId']) + const position = parsePositionXml(entry.position) + const outputHandle = makeHandle( + LEAF_OUTPUT_HANDLE, + 'source', + Position.Right, + position, + asRecord(entry.connectionPointOut).relPosition, + ) + + return { + id: `INPUT-VARIABLE-${numericId}`, + type: 'variable', + position, + width: toNumber(entry['@width']), + height: toNumber(entry['@height']), + draggable: true, + selectable: true, + data: { + handles: [outputHandle], + inputHandles: [], + outputHandles: [outputHandle], + inputConnector: undefined, + outputConnector: outputHandle, + numericId, + variable: { name: asString(entry.expression) }, + executionOrder: 0, + draggable: true, + selectable: true, + deletable: true, + variant: 'input', + // Which block/pin this literal feeds can't be recovered here (only + // the block's own entry names its source by + // refLocalId, not the reverse) — left as an honest placeholder; the + // edge built from that block's connection is the source of truth. + block: { id: '', handleId: '', variableType: { name: '', class: '', type: { definition: 'base-type', value: '' } } }, + }, + } +} + +function parseOutVariableXml(entry: Record): { node: VariableNode; pendingEdges: PendingEdge[] } { + const numericId = asString(entry['@localId']) + const position = parsePositionXml(entry.position) + const connIn = asRecord(entry.connectionPointIn) + const inputHandle = makeHandle(LEAF_INPUT_HANDLE, 'target', Position.Left, position, connIn.relPosition) + const connections = asArray(connIn.connection) + const pendingEdges = connections.map((connRaw) => parseConnectionXml(connRaw, numericId, LEAF_INPUT_HANDLE)) + + // outVariableToXML always emits exactly one connection, built directly + // from data.block.{id,handleId} rather than through findConnections — the + // one place the generator trusts that bookkeeping over the edge graph. + // Reversed here: refLocalId/formalParameter identify the source block by + // numericId, but `block.id` wants the block's own xyflow id, which isn't + // known until the second pass — left blank and not otherwise relied upon + // (the edge itself is the source of truth for wiring). + const firstConnection = asRecord(connections[0]) + const blockHandleId = asString(firstConnection['@formalParameter']) + + return { + node: { + id: `OUTPUT-VARIABLE-${numericId}`, + type: 'variable', + position, + width: toNumber(entry['@width']), + height: toNumber(entry['@height']), + draggable: true, + selectable: true, + data: { + handles: [inputHandle], + inputHandles: [inputHandle], + outputHandles: [], + inputConnector: inputHandle, + outputConnector: undefined, + numericId, + variable: { name: asString(entry.expression) }, + executionOrder: 0, + draggable: true, + selectable: true, + deletable: true, + variant: 'output', + block: { + id: '', + handleId: blockHandleId, + variableType: { name: '', class: '', type: { definition: 'base-type', value: '' } }, + }, + }, + }, + pendingEdges, + } +} + +// Simple union-find for grouping the flat XML's nodes back into rungs (see +// parseLadderXml below for why this is necessary rather than a positional +// grouping). +class UnionFind { + private readonly parent = new Map() + + find(x: string): string { + if (!this.parent.has(x)) this.parent.set(x, x) + let root = x + while (this.parent.get(root) !== root) root = this.parent.get(root) as string + let cur = x + while (this.parent.get(cur) !== root) { + const next = this.parent.get(cur) as string + this.parent.set(cur, root) + cur = next + } + return root + } + + union(a: string, b: string): void { + const ra = this.find(a) + const rb = this.find(b) + if (ra !== rb) this.parent.set(ra, rb) + } +} + +export function parseLadderXml(pouName: string, ldXml: unknown): { body: LadderFlowType; warnings: string[] } { + const ld = asRecord(ldXml) + const warnings: string[] = [] + const nodes: LadderParsedNode[] = [] + const nodeIdByNumericId = new Map() + const pendingEdges: PendingEdge[] = [] + + for (const entry of asArray(ld.leftPowerRail)) { + const node = parseLeftRailXml(asRecord(entry)) + nodes.push(node) + nodeIdByNumericId.set(node.data.numericId, node.id) + } + for (const entry of asArray(ld.rightPowerRail)) { + const { node, pendingEdges: edges } = parseRightRailXml(asRecord(entry)) + nodes.push(node) + nodeIdByNumericId.set(node.data.numericId, node.id) + pendingEdges.push(...edges) + } + for (const entry of asArray(ld.contact)) { + const { node, pendingEdges: edges } = parseContactXml(asRecord(entry)) + nodes.push(node) + nodeIdByNumericId.set(node.data.numericId, node.id) + pendingEdges.push(...edges) + } + for (const entry of asArray(ld.coil)) { + const { node, pendingEdges: edges } = parseCoilXml(asRecord(entry)) + nodes.push(node) + nodeIdByNumericId.set(node.data.numericId, node.id) + pendingEdges.push(...edges) + } + for (const entry of asArray(ld.block)) { + const { node, pendingEdges: edges } = parseBlockXml(asRecord(entry)) + nodes.push(node) + nodeIdByNumericId.set(node.data.numericId, node.id) + pendingEdges.push(...edges) + } + for (const entry of asArray(ld.inVariable)) { + const node = parseInVariableXml(asRecord(entry)) + nodes.push(node) + nodeIdByNumericId.set(node.data.numericId, node.id) + } + for (const entry of asArray(ld.outVariable)) { + const { node, pendingEdges: edges } = parseOutVariableXml(asRecord(entry)) + nodes.push(node) + nodeIdByNumericId.set(node.data.numericId, node.id) + pendingEdges.push(...edges) + } + + const inOutCount = asArray(ld.inOutVariable).length + if (inOutCount > 0) { + warnings.push(`POU "${pouName}": ${inOutCount} LD inOutVariable node(s) are not supported, skipped`) + } + + const edges: Edge[] = [] + const forest = new UnionFind() + for (const node of nodes) forest.find(node.id) + + for (const pending of pendingEdges) { + const targetNodeId = nodeIdByNumericId.get(pending.targetNumericId) + const sourceNodeId = nodeIdByNumericId.get(pending.sourceRefLocalId) + if (!targetNodeId || !sourceNodeId) { + warnings.push( + `POU "${pouName}": LD connection references unknown localId "${pending.sourceRefLocalId}", skipped`, + ) + continue + } + const sourceHandle = pending.sourceFormalParameter ?? LEAF_OUTPUT_HANDLE + edges.push({ + id: `xy-edge__${sourceNodeId}${sourceHandle}-${targetNodeId}${pending.targetHandle}`, + source: sourceNodeId, + sourceHandle, + target: targetNodeId, + targetHandle: pending.targetHandle, + type: 'smoothstep', + }) + forest.union(sourceNodeId, targetNodeId) + } + + // Rungs aren't wrapped by any XML element in this dialect — all rungs + // flatten into one shared (see ladderToXml) and are only + // reconstructable by tracing which nodes are connected to each other. + // Rungs never cross-connect, so a connected-component partition of the + // node/edge graph recovers them, without needing the array-position + // pairing the generator's own output happens to preserve. + const componentOrder: string[] = [] + const componentNodes = new Map() + for (const node of nodes) { + const root = forest.find(node.id) + const group = componentNodes.get(root) + if (group) { + group.push(node) + } else { + componentNodes.set(root, [node]) + componentOrder.push(root) + } + } + + // Rung stacking (offsetY in the generator) bakes a cumulative Y shift into + // every node's position; re-basing each rung to a local origin would need + // to rebuild every node-data variant's handles generically, which TS can't + // do without a type assertion across this discriminated union — kept as + // absolute coordinates instead (still internally consistent per rung; a + // reopened diagram just starts further down the canvas for later rungs). + const rungs: LadderFlowType['rungs'] = componentOrder.map((root, index) => { + const rungNodeIds = new Set(componentNodes.get(root)?.map((n) => n.id)) + const rungEdges = edges.filter((e) => rungNodeIds.has(e.source) && rungNodeIds.has(e.target)) + const rungNodes = componentNodes.get(root) ?? [] + + const minX = Math.min(...rungNodes.map((n) => n.position.x)) + const minY = Math.min(...rungNodes.map((n) => n.position.y)) + const maxX = Math.max(...rungNodes.map((n) => n.position.x + (n.width ?? 0))) + const maxY = Math.max(...rungNodes.map((n) => n.position.y + (n.height ?? 0))) + + return { + id: `rung-${index}`, + comment: '', + defaultBounds: [minX, minY, maxX, maxY], + reactFlowViewport: [maxX - minX, maxY - minY], + selectedNodes: [], + nodes: rungNodes, + edges: rungEdges, + } + }) + + return { body: { name: pouName, updated: false, rungs }, warnings } +} diff --git a/src/frontend/utils/PLC/xml-parser/parse-xml-document.ts b/src/frontend/utils/PLC/xml-parser/parse-xml-document.ts new file mode 100644 index 000000000..01d36bbd2 --- /dev/null +++ b/src/frontend/utils/PLC/xml-parser/parse-xml-document.ts @@ -0,0 +1,34 @@ +import { XMLParser } from 'fast-xml-parser' + +import { asRecord } from './xml-node' + +// Elements the old-editor generator always emits as a list, even with 0 or 1 +// items (see xml-generator/old-editor/*.ts) — fast-xml-parser otherwise +// collapses a single child into a bare object, which would break every +// downstream `.map()`/`.forEach()` over "the list of X". +const ARRAY_TAGS = new Set(['dataType', 'pou', 'task', 'pouInstance', 'variable', 'dimension', 'value']) + +const parser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '@', + textNodeName: '$', + parseTagValue: false, + parseAttributeValue: false, + isArray: (name) => ARRAY_TAGS.has(name), +}) + +// Parses raw PLCopen XML text into the untyped object tree fast-xml-parser +// produces. Deliberately returns `Record`, not a typed +// shape — the vendored xml-types zod schemas (xml-generator/old-editor/*) +// are known to be narrower than what the generator itself can emit (e.g. +// `derived`/`array` variable types aren't in every schema), so validating +// against them here would silently strip data. Downstream parser modules +// narrow field by field instead. +export function parseXmlDocument(xml: string): Record { + const result = asRecord(parser.parse(xml)) + const project = asRecord(result.project) + if (Object.keys(project).length === 0) { + throw new Error('Invalid PLCopen XML: missing root element') + } + return project +} diff --git a/src/frontend/utils/PLC/xml-parser/pou-xml.ts b/src/frontend/utils/PLC/xml-parser/pou-xml.ts new file mode 100644 index 000000000..d8b9b74b2 --- /dev/null +++ b/src/frontend/utils/PLC/xml-parser/pou-xml.ts @@ -0,0 +1,120 @@ +import type { PLCPou, PLCVariable, PouType, VariableClass } from '../../../../middleware/shared/ports/types' +import { lookupBaseTypeByXmlElement } from '../../iec-types-registry' +import { parseFbdXml } from './language/fbd-xml' +import { parseLadderXml } from './language/ladder-xml' +import { extractXhtmlText, parseDocumentationXml, parseVariableXml } from './variable-xml' +import { asArray, asRecord, asString } from './xml-node' + +const VAR_GROUP_TO_CLASS: Record = { + inputVars: 'input', + outputVars: 'output', + inOutVars: 'inOut', + externalVars: 'external', + localVars: 'local', + tempVars: 'temp', +} + +const POU_TYPE_FROM_XML: Record = { + program: 'program', + function: 'function', + functionBlock: 'function-block', +} + +// Reverse of `oldEditorParseInterface` (xml-generator/old-editor/pou-xml.ts). +export function parseInterfaceXml(interfaceXml: unknown): { variables: PLCVariable[]; returnType?: string } { + const iface = asRecord(interfaceXml) + const variables: PLCVariable[] = [] + + for (const [group, variableClass] of Object.entries(VAR_GROUP_TO_CLASS)) { + const groupXml = asRecord(iface[group]) + for (const varXml of asArray(groupXml.variable)) { + variables.push(parseVariableXml(varXml, variableClass)) + } + } + + if (!iface.returnType) return { variables } + + const returnTypeXml = asRecord(iface.returnType) + if ('derived' in returnTypeXml) { + return { variables, returnType: asString(asRecord(returnTypeXml.derived)['@name']) } + } + const tag = Object.keys(returnTypeXml)[0] + return { variables, returnType: tag !== undefined ? (lookupBaseTypeByXmlElement(tag)?.name ?? tag) : undefined } +} + +// Reverse of `oldEditorParsePousToXML`. ST/IL/LD/FBD bodies all parse in +// full; SFC and codesys-dialect bodies are surfaced as a non-fatal warning +// and the POU is skipped — this importer's scope is the old-editor dialect +// only (see xml-parser/index.ts). +export function parsePousXml(pouXml: unknown): { pous: PLCPou[]; warnings: string[] } { + const pous: PLCPou[] = [] + const warnings: string[] = [] + + for (const entryRaw of asArray(pouXml)) { + const entry = asRecord(entryRaw) + const name = asString(entry['@name']) + const pouTypeXml = asString(entry['@pouType']) + const type = POU_TYPE_FROM_XML[pouTypeXml] + if (!type) { + warnings.push(`POU "${name}": unrecognized pouType "${pouTypeXml}", skipped`) + continue + } + + const body = asRecord(entry.body) + const { variables, returnType } = parseInterfaceXml(entry.interface) + const documentation = parseDocumentationXml(entry.documentation) + const pouInterface = { variables, ...(returnType !== undefined ? { returnType } : {}) } + + if (body.ST !== undefined) { + pous.push({ + name, + pouType: type, + interface: pouInterface, + body: { language: 'st', value: extractXhtmlText(body.ST) }, + documentation, + }) + continue + } + if (body.IL !== undefined) { + pous.push({ + name, + pouType: type, + interface: pouInterface, + body: { language: 'il', value: extractXhtmlText(body.IL) }, + documentation, + }) + continue + } + if (body.LD !== undefined) { + const { body: ldBody, warnings: ldWarnings } = parseLadderXml(name, body.LD) + warnings.push(...ldWarnings) + pous.push({ + name, + pouType: type, + interface: pouInterface, + body: { language: 'ld', value: ldBody }, + documentation, + }) + continue + } + if (body.FBD !== undefined) { + const { body: fbdBody, warnings: fbdWarnings } = parseFbdXml(name, body.FBD) + warnings.push(...fbdWarnings) + pous.push({ + name, + pouType: type, + interface: pouInterface, + body: { language: 'fbd', value: fbdBody }, + documentation, + }) + continue + } + if (body.SFC !== undefined) { + warnings.push(`POU "${name}": Sequential Function Chart is not supported by the importer, skipped`) + continue + } + warnings.push(`POU "${name}": no recognized body language found, skipped`) + } + + return { pous, warnings } +} diff --git a/src/frontend/utils/PLC/xml-parser/type-xml.ts b/src/frontend/utils/PLC/xml-parser/type-xml.ts new file mode 100644 index 000000000..225c7d0e7 --- /dev/null +++ b/src/frontend/utils/PLC/xml-parser/type-xml.ts @@ -0,0 +1,48 @@ +import type { PLCVariableType } from '../../../../middleware/shared/ports/types' +import { lookupBaseTypeByXmlElement } from '../../iec-types-registry' +import { asArray, asRecord, asString } from './xml-node' + +type LeafBaseType = { definition: 'base-type' | 'user-data-type'; value: string } + +// Reverse of `convertTypeToXml` (xml-generator/old-editor/type-xml.ts): a +// PLCopen ``/`` element has exactly one child key, which is +// either a recognised IEC base-type tag, `derived` (user type/FB reference), +// or `array` (nested dimensions + element base type). +function parseBaseTypeLeaf(baseTypeXml: unknown): LeafBaseType { + const rec = asRecord(baseTypeXml) + if ('derived' in rec) { + return { definition: 'user-data-type', value: asString(asRecord(rec.derived)['@name']) } + } + const tag = Object.keys(rec)[0] + if (tag === undefined) throw new Error('Type element has no recognizable base type') + return { definition: 'base-type', value: lookupBaseTypeByXmlElement(tag)?.name ?? tag } +} + +function parseDimensionsXml(dimensionXml: unknown): Array<{ dimension: string }> { + return asArray(dimensionXml).map((d) => { + const dim = asRecord(d) + return { dimension: `${asString(dim['@lower'])}..${asString(dim['@upper'])}` } + }) +} + +export function parseTypeXml(typeXml: unknown): PLCVariableType { + const type = asRecord(typeXml) + + if ('array' in type) { + const arrayXml = asRecord(type.array) + const baseType = parseBaseTypeLeaf(arrayXml.baseType) + const dimensions = parseDimensionsXml(arrayXml.dimension) + const value = `ARRAY[${dimensions.map((d) => d.dimension).join(',')}] OF ${baseType.value}` + return { definition: 'array', value, data: { baseType, dimensions } } + } + + if ('derived' in type) { + return { definition: 'derived', value: asString(asRecord(type.derived)['@name']) } + } + + const tag = Object.keys(type)[0] + if (tag === undefined) throw new Error('Variable type element is empty') + return { definition: 'base-type', value: lookupBaseTypeByXmlElement(tag)?.name ?? tag } +} + +export { parseBaseTypeLeaf, parseDimensionsXml } diff --git a/src/frontend/utils/PLC/xml-parser/variable-xml.ts b/src/frontend/utils/PLC/xml-parser/variable-xml.ts new file mode 100644 index 000000000..c3c9210ca --- /dev/null +++ b/src/frontend/utils/PLC/xml-parser/variable-xml.ts @@ -0,0 +1,45 @@ +import type { PLCVariable, VariableClass } from '../../../../middleware/shared/ports/types' +import { parseTypeXml } from './type-xml' +import { asRecord } from './xml-node' + +// A bare `text` (no attributes) parses to a plain string; +// fast-xml-parser only wraps it in `{ $: text }` when attributes are present. +function extractXhtmlText(xml: unknown): string { + const rec = asRecord(xml) + const p = rec['xhtml:p'] + if (typeof p === 'string') return p + const text = asRecord(p).$ + return typeof text === 'string' ? text : '' +} + +// The generator writes a literal single space for an empty documentation +// string (`value === '' ? ' ' : value`, see oldEditorParseInterface/ +// oldEditorParsePousToXML) — reverse that placeholder back to ''. ST/IL body +// text has no such placeholder, so callers reading a body use +// `extractXhtmlText` directly instead of this function. +export function parseDocumentationXml(xml: unknown): string { + const text = extractXhtmlText(xml) + return text === ' ' ? '' : text +} + +export { extractXhtmlText } + +// Reverse of the `VariableXML` shape built in oldEditorParseInterface / +// oldEditorInstanceToXml — shared by POU interface variables and +// configuration global variables (only `class` differs by call site). +export function parseVariableXml(varXml: unknown, variableClass: VariableClass): PLCVariable { + const v = asRecord(varXml) + const initialValueXml = asRecord(v.initialValue) + const simpleValue = asRecord(initialValueXml.simpleValue) + const initialValue = typeof simpleValue['@value'] === 'string' ? (simpleValue['@value']) : null + const location = v['@address'] + + return { + name: typeof v['@name'] === 'string' ? v['@name'] : '', + class: variableClass, + type: parseTypeXml(v.type), + location: typeof location === 'string' ? location : '', + initialValue, + documentation: parseDocumentationXml(v.documentation), + } +} diff --git a/src/frontend/utils/PLC/xml-parser/xml-node.ts b/src/frontend/utils/PLC/xml-parser/xml-node.ts new file mode 100644 index 000000000..001ad34e3 --- /dev/null +++ b/src/frontend/utils/PLC/xml-parser/xml-node.ts @@ -0,0 +1,17 @@ +// Small defensive helpers for navigating fast-xml-parser's untyped output. +// The parser config (parse-xml-document.ts) forces known repeating elements +// into arrays, but leaf/absent values still arrive as `unknown` — e.g. an +// empty element (``) parses to `''`, not `{}` or `undefined`. + +export function asRecord(value: unknown): Record { + return typeof value === 'object' && value !== null ? (value as Record) : {} +} + +export function asArray(value: T | T[] | undefined): T[] { + if (value === undefined) return [] + return Array.isArray(value) ? value : [value] +} + +export function asString(value: unknown): string { + return typeof value === 'string' ? value : '' +} diff --git a/src/frontend/utils/__tests__/iec-types-registry.test.ts b/src/frontend/utils/__tests__/iec-types-registry.test.ts index 048643f2a..589c7e0ee 100644 --- a/src/frontend/utils/__tests__/iec-types-registry.test.ts +++ b/src/frontend/utils/__tests__/iec-types-registry.test.ts @@ -1,4 +1,4 @@ -import { BASE_TYPE_NAMES, IEC_BASE_TYPES, isBaseTypeName, lookupBaseType } from '../iec-types-registry' +import { BASE_TYPE_NAMES, IEC_BASE_TYPES, isBaseTypeName, lookupBaseType, lookupBaseTypeByXmlElement } from '../iec-types-registry' describe('iec-types-registry', () => { describe('IEC_BASE_TYPES', () => { @@ -81,6 +81,36 @@ describe('iec-types-registry', () => { }) }) + describe('lookupBaseTypeByXmlElement', () => { + it('resolves the standard uppercase element names', () => { + expect(lookupBaseTypeByXmlElement('BOOL')?.name).toBe('BOOL') + expect(lookupBaseTypeByXmlElement('INT')?.name).toBe('INT') + expect(lookupBaseTypeByXmlElement('REAL')?.name).toBe('REAL') + }) + + it('resolves the lowercase string/wstring element names', () => { + expect(lookupBaseTypeByXmlElement('string')?.name).toBe('STRING') + expect(lookupBaseTypeByXmlElement('wstring')?.name).toBe('WSTRING') + }) + + it('is case-sensitive (does not match on the wrong case)', () => { + expect(lookupBaseTypeByXmlElement('STRING')).toBeUndefined() + expect(lookupBaseTypeByXmlElement('WSTRING')).toBeUndefined() + expect(lookupBaseTypeByXmlElement('bool')).toBeUndefined() + }) + + it('returns undefined for unknown element names', () => { + expect(lookupBaseTypeByXmlElement('derived')).toBeUndefined() + expect(lookupBaseTypeByXmlElement('')).toBeUndefined() + }) + + it("round-trips every registry entry's own xml.elementName", () => { + for (const t of IEC_BASE_TYPES) { + expect(lookupBaseTypeByXmlElement(t.xml.elementName)).toBe(t) + } + }) + }) + describe('BASE_TYPE_NAMES', () => { it('exposes canonical names only (no aliases)', () => { expect(BASE_TYPE_NAMES).toContain('TOD') diff --git a/src/frontend/utils/iec-types-registry.ts b/src/frontend/utils/iec-types-registry.ts index b08fadb11..0868eb0fd 100644 --- a/src/frontend/utils/iec-types-registry.ts +++ b/src/frontend/utils/iec-types-registry.ts @@ -119,3 +119,27 @@ export function isBaseTypeName(name: string): boolean { * spelling we want to see in UI dropdowns and emit in PLCopen XML. */ export const BASE_TYPE_NAMES: readonly string[] = IEC_BASE_TYPES.map((t) => t.name) + +/** + * Reverse index of {@link IEC_BASE_TYPES}, keyed by the literal PLCopen XML + * element name (`t.xml.elementName` — e.g. `BOOL`, `string`), for the + * PLCopen XML importer (frontend/utils/PLC/xml-parser/type-xml.ts). Element + * names are unique across the registry (case-sensitive, mixed-case by + * design — see `baseTypeTag`), so no collision handling is needed. + */ +const XML_ELEMENT_INDEX: ReadonlyMap = (() => { + const m = new Map() + for (const t of IEC_BASE_TYPES) m.set(t.xml.elementName, t) + return m +})() + +/** + * Resolve a PLCopen XML element name (e.g. `BOOL`, `string`) back to its + * IEC type metadata. Case-sensitive — unlike `lookupBaseType`, callers here + * already have the exact tag fast-xml-parser handed them, and PLCopen XML + * element names are case-significant (`` vs `` are not + * interchangeable — xml2st rejects the latter). + */ +export function lookupBaseTypeByXmlElement(elementName: string): IECTypeMetadata | undefined { + return XML_ELEMENT_INDEX.get(elementName) +} diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index 4bad31169..3f12df229 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -42,7 +42,12 @@ import { ModbusTcpClient } from '../../../backend/editor/modbus/modbus-client' import { ModbusRtuClient } from '../../../backend/editor/modbus/modbus-rtu-client' import { PackageManagerModule } from '../../../backend/editor/package-manager' import { logger } from '../../../backend/editor/services' -import { getOpenProjectPath, getProjectPath } from '../../../backend/editor/utils' +import { + getOpenProjectPath, + getPlcopenExportSavePath, + getPlcopenImportFilePath, + getProjectPath, +} from '../../../backend/editor/utils' import { WebSocketDebugTransport } from '../../../backend/shared/debug/websocket-debug-transport' import { SimulatorModule } from '../../../backend/shared/simulator/simulator-module' import { VirtualSerialPort } from '../../../backend/shared/simulator/virtual-serial-port' @@ -816,6 +821,8 @@ class MainProcessBridge implements MainIpcModule { this.registerHandle('project:save-file', this.handleFileSave) this.registerHandle('project:open-by-path', this.handleProjectOpenByPath) this.registerHandle('project:read-files', this.handleReadProjectFiles) + this.registerHandle('project:pick-plcopen-import-file', this.handlePickPlcopenImportFile) + this.registerHandle('project:export-plcopen-file', this.handleExportPlcopenFile) // Pou-related handlers this.registerHandle('pou:create', this.handleCreatePouFile) @@ -1043,6 +1050,36 @@ class MainProcessBridge implements MainIpcModule { } } + handlePickPlcopenImportFile = async (_event: IpcMainInvokeEvent) => { + const windowManager = this.mainWindow + try { + if (windowManager) { + const res = await getPlcopenImportFilePath(windowManager) + return res + } + logger.error('Window object not defined') + return { success: false, error: { title: 'Internal error', description: 'Window object not defined' } } + } catch (error) { + logger.error('Error picking PLCopen import file: ' + getErrorMessage(error)) + return { success: false, error: { title: 'Internal error', description: getErrorMessage(error) } } + } + } + + handleExportPlcopenFile = async (_event: IpcMainInvokeEvent, defaultFileName: string, xml: string) => { + const windowManager = this.mainWindow + try { + if (windowManager) { + const res = await getPlcopenExportSavePath(windowManager, defaultFileName, xml) + return res + } + logger.error('Window object not defined') + return { success: false, error: { title: 'Internal error', description: 'Window object not defined' } } + } catch (error) { + logger.error('Error exporting PLCopen file: ' + getErrorMessage(error)) + return { success: false, error: { title: 'Internal error', description: getErrorMessage(error) } } + } + } + // Pou-related handlers handleCreatePouFile = async (_event: IpcMainInvokeEvent, props: CreatePouFileProps) => { try { diff --git a/src/main/modules/ipc/renderer.ts b/src/main/modules/ipc/renderer.ts index 395d24d3e..67393124e 100644 --- a/src/main/modules/ipc/renderer.ts +++ b/src/main/modules/ipc/renderer.ts @@ -73,6 +73,16 @@ const rendererProcessBridge = { openPathPicker: (): Promise<{ success: boolean; error?: { title: string; description: string }; path?: string }> => ipcRenderer.invoke('project:open-path-picker'), readProjectFiles: (projectPath: string): Promise => ipcRenderer.invoke('project:read-files', projectPath), + pickPlcopenImportFile: (): Promise<{ + success: boolean + content?: string + error?: { title: string; description: string } + }> => ipcRenderer.invoke('project:pick-plcopen-import-file'), + exportPlcopenFile: ( + defaultFileName: string, + xml: string, + ): Promise<{ success: boolean; error?: { title: string; description: string } }> => + ipcRenderer.invoke('project:export-plcopen-file', defaultFileName, xml), removeCloseProjectListener: () => ipcRenderer.removeAllListeners('workspace:close-project-accelerator'), removeCloseTabListener: () => ipcRenderer.removeAllListeners('workspace:close-tab-accelerator'), removeCreateProjectAccelerator: () => ipcRenderer.removeAllListeners('project:create-accelerator'), diff --git a/src/middleware/adapters/editor/__tests__/project-adapter.test.ts b/src/middleware/adapters/editor/__tests__/project-adapter.test.ts index 532a35db8..58e54eebe 100644 --- a/src/middleware/adapters/editor/__tests__/project-adapter.test.ts +++ b/src/middleware/adapters/editor/__tests__/project-adapter.test.ts @@ -121,6 +121,8 @@ beforeEach(() => { onFileExternalChange: jest.fn().mockImplementation((_cb: unknown) => { return () => {} }), + pickPlcopenImportFile: jest.fn().mockResolvedValue({ success: true, content: '' }), + exportPlcopenFile: jest.fn().mockResolvedValue({ success: true }), } as unknown as typeof window.bridge }) @@ -552,6 +554,62 @@ describe('createEditorProjectAdapter', () => { }) }) + describe('pickPlcopenImportFile', () => { + it('delegates to window.bridge.pickPlcopenImportFile and returns content', async () => { + const result = await adapter.pickPlcopenImportFile() + + expect(window.bridge.pickPlcopenImportFile).toHaveBeenCalledTimes(1) + expect(result).toEqual({ success: true, content: '' }) + }) + + it('flattens the error object to a string on failure', async () => { + ;(window.bridge.pickPlcopenImportFile as jest.Mock).mockResolvedValue({ + success: false, + error: { title: 'Operation canceled', description: 'Operation canceled by the user.' }, + }) + + const result = await adapter.pickPlcopenImportFile() + + expect(result).toEqual({ success: false, error: 'Operation canceled by the user.' }) + }) + + it('returns undefined error when the bridge reports failure without an error object', async () => { + ;(window.bridge.pickPlcopenImportFile as jest.Mock).mockResolvedValue({ success: false }) + + const result = await adapter.pickPlcopenImportFile() + + expect(result).toEqual({ success: false, error: undefined }) + }) + }) + + describe('exportPlcopenFile', () => { + it('delegates to window.bridge.exportPlcopenFile with the file name and xml content', async () => { + const result = await adapter.exportPlcopenFile('my-project.xml', '') + + expect(window.bridge.exportPlcopenFile).toHaveBeenCalledWith('my-project.xml', '') + expect(result).toEqual({ success: true }) + }) + + it('flattens the error object to a string on failure', async () => { + ;(window.bridge.exportPlcopenFile as jest.Mock).mockResolvedValue({ + success: false, + error: { title: 'Error writing file', description: 'Failed to write the PLCopen XML file.' }, + }) + + const result = await adapter.exportPlcopenFile('my-project.xml', '') + + expect(result).toEqual({ success: false, error: 'Failed to write the PLCopen XML file.' }) + }) + + it('returns undefined error when the bridge reports failure without an error object', async () => { + ;(window.bridge.exportPlcopenFile as jest.Mock).mockResolvedValue({ success: false }) + + const result = await adapter.exportPlcopenFile('my-project.xml', '') + + expect(result).toEqual({ success: false, error: undefined }) + }) + }) + describe('onFileExternalChange', () => { it('subscribes via window.bridge.onFileExternalChange and returns unsubscribe', () => { const callback = jest.fn() diff --git a/src/middleware/adapters/editor/project-adapter.ts b/src/middleware/adapters/editor/project-adapter.ts index 2420eee8d..a6caf6c30 100644 --- a/src/middleware/adapters/editor/project-adapter.ts +++ b/src/middleware/adapters/editor/project-adapter.ts @@ -332,6 +332,22 @@ export function createEditorProjectAdapter(): ProjectPort { callback(data.filePath) }) }, + + async pickPlcopenImportFile(): Promise<{ success: boolean; content?: string; error?: string }> { + const response = await window.bridge.pickPlcopenImportFile() + if (!response.success) { + return { success: false, error: response.error?.description } + } + return { success: true, content: response.content } + }, + + async exportPlcopenFile(defaultFileName: string, xml: string): Promise<{ success: boolean; error?: string }> { + const response = await window.bridge.exportPlcopenFile(defaultFileName, xml) + if (!response.success) { + return { success: false, error: response.error?.description } + } + return { success: true } + }, } } diff --git a/src/middleware/shared/ports/platform-capabilities.ts b/src/middleware/shared/ports/platform-capabilities.ts index b3036e01b..95bd82bd4 100644 --- a/src/middleware/shared/ports/platform-capabilities.ts +++ b/src/middleware/shared/ports/platform-capabilities.ts @@ -48,6 +48,9 @@ export interface PlatformCapabilities { /** True if the app supports exporting projects as XML files (Codesys, old-editor formats). */ hasProjectExport: boolean + /** True if the app supports importing a project from a PLCopen XML file. */ + hasProjectImport: boolean + /** True if the app supports version control (branches, commits, change tracking). */ hasVersionControl: boolean @@ -121,6 +124,7 @@ export const EDITOR_CAPABILITIES: PlatformCapabilities = { hasInProcessSimulator: true, hasLocalFilesystem: true, hasProjectExport: true, + hasProjectImport: true, hasVersionControl: false, hasAboutDialog: true, hasPythonLSP: true, @@ -147,7 +151,10 @@ export const WEB_CAPABILITIES: PlatformCapabilities = { hasWebRTC: true, hasInProcessSimulator: true, hasLocalFilesystem: false, - hasProjectExport: false, + // Browser-download implementation makes export just as viable on web + // as on desktop — no reason to keep this gated off. + hasProjectExport: true, + hasProjectImport: true, hasVersionControl: true, hasAboutDialog: true, // `monaco-pyright-lsp` ships its own ESM worker via diff --git a/src/middleware/shared/ports/project-port.ts b/src/middleware/shared/ports/project-port.ts index 5a80ff9d5..b52eecca3 100644 --- a/src/middleware/shared/ports/project-port.ts +++ b/src/middleware/shared/ports/project-port.ts @@ -76,6 +76,16 @@ export interface ProjectResponse { * expose READMEs (desktop editor, dev:local). */ readme?: string | null + /** + * Signals this response was just converted from a pending raw PLCopen + * import (Node's `plcopen-pending-import.xml` marker) rather than + * loaded from a normal `project.json`. Set only by the adapter branch + * that runs `parsePlcopenXml` in place of `parseProjectFiles`. The + * caller should persist immediately (`saveProject`) so the marker gets + * pruned server-side — Node's save endpoint deletes any file not in + * the incoming payload. Absent ⇒ ordinary open/import, no auto-save. + */ + wasPendingPlcopenImport?: boolean } error?: { title: string @@ -169,6 +179,14 @@ export interface RawProjectFiles { /** See {@link ProjectResponse.data.readme}. Carried through the * raw layer for the same reason as `canEdit`. */ readme?: string | null + /** + * Raw PLCopen XML content when the project directory is a bare + * pending-import marker (Node's `plcopen-pending-import.xml`) instead + * of a normal project — `apiFilesToRaw` surfaces the envelope's + * `'plcopen-pending-import.xml'` key here. `undefined` is the + * "not pending" case (normal project, has `project.json`). + */ + pendingPlcopenSource?: string } error?: { title: string; description: string } } @@ -312,4 +330,18 @@ export interface ProjectPort { migrated?: boolean error?: string }> + + /** + * Pick a PLCopen XML file to import and read its contents. + * Editor: native open-file dialog filtered to .xml. + * Web: hidden . + */ + pickPlcopenImportFile(): Promise<{ success: boolean; content?: string; error?: string }> + + /** + * Persist generated PLCopen XML content as a file the user can access. + * Editor: native save-file dialog, writes to disk. + * Web: triggers a browser download of the blob. + */ + exportPlcopenFile(defaultFileName: string, xml: string): Promise<{ success: boolean; error?: string }> } From d2a83f82f0ad59f8f5b0e15dcb8271076833b057 Mon Sep 17 00:00:00 2001 From: Daniel Coutinho <60111446+dcoutinho1328@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:18:25 -0300 Subject: [PATCH 2/4] fix(plcopen): register confirm-plcopen-import in ALL_MODAL_TYPES closeModal() only resets modals listed in ALL_MODAL_TYPES, so the File -> Import PLCopen XML confirm dialog stayed open after a successful import since its type was missing from that list. Co-Authored-By: Claude Sonnet 5 --- src/frontend/store/slices/modal/slice.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/frontend/store/slices/modal/slice.ts b/src/frontend/store/slices/modal/slice.ts index bb89930c9..cecff18e4 100644 --- a/src/frontend/store/slices/modal/slice.ts +++ b/src/frontend/store/slices/modal/slice.ts @@ -27,6 +27,7 @@ const ALL_MODAL_TYPES: ModalTypes[] = [ 'public-catalog-browser', 'confirm-install-libraries', 'project-readme', + 'confirm-plcopen-import', ] function createDefaultModals() { From 2ef0feb200943e3898eb18a97a53ed020f7dc90c Mon Sep 17 00:00:00 2001 From: Daniel Coutinho <60111446+dcoutinho1328@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:46:14 -0300 Subject: [PATCH 3/4] fix(plcopen): sort imports to satisfy simple-import-sort lint rule Co-Authored-By: Claude Sonnet 5 --- src/frontend/services/export-actions.ts | 2 +- src/frontend/services/import-actions.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/frontend/services/export-actions.ts b/src/frontend/services/export-actions.ts index da90d4000..fa57755ca 100644 --- a/src/frontend/services/export-actions.ts +++ b/src/frontend/services/export-actions.ts @@ -14,10 +14,10 @@ * sidecars) since this is a plain export, not a compile. */ +import { XmlGenerator } from '../../backend/shared/utils/PLC/xml-generator' import type { PLCProjectData as SchemaPLCProjectData } from '../../middleware/shared/ports/open-plc-types' import type { ProjectPort } from '../../middleware/shared/ports/project-port' import type { PLCProjectData, PouLanguage } from '../../middleware/shared/ports/types' -import { XmlGenerator } from '../../backend/shared/utils/PLC/xml-generator' import { openPLCStoreBase } from '../store' import { toast } from '../utils/toast' diff --git a/src/frontend/services/import-actions.ts b/src/frontend/services/import-actions.ts index 674e124a7..e5a102e62 100644 --- a/src/frontend/services/import-actions.ts +++ b/src/frontend/services/import-actions.ts @@ -6,9 +6,9 @@ * in-memory project data is replaced with what the XML parses to. */ -import type { OpenProjectResponseData } from '../store/slices/shared/types' import type { ProjectPort } from '../../middleware/shared/ports/project-port' import { openPLCStoreBase } from '../store' +import type { OpenProjectResponseData } from '../store/slices/shared/types' import { buildProjectResponseFromPlcopenParse } from '../utils/PLC/build-plcopen-project-response' import { parsePlcopenXml } from '../utils/PLC/xml-parser' import { toast } from '../utils/toast' From 627e5dc75b3ec08ec08f1ec1e5afc1a47252ec96 Mon Sep 17 00:00:00 2001 From: Daniel Coutinho <60111446+dcoutinho1328@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:39:48 -0300 Subject: [PATCH 4/4] style: reformat with prettier after merging development Co-Authored-By: Claude Sonnet 5 --- src/backend/editor/utils/path-picker.ts | 6 +- .../confirm-plcopen-import-modal.test.tsx | 4 +- .../services/__tests__/import-actions.test.ts | 24 ++- .../__tests__/instances-xml.test.ts | 9 +- .../__tests__/parse-plcopen-xml.test.ts | 155 +++++++++++++++--- .../language/__tests__/ladder-xml.test.ts | 34 ++-- .../PLC/xml-parser/language/ladder-xml.ts | 14 +- .../utils/PLC/xml-parser/variable-xml.ts | 2 +- .../__tests__/iec-types-registry.test.ts | 8 +- 9 files changed, 202 insertions(+), 54 deletions(-) diff --git a/src/backend/editor/utils/path-picker.ts b/src/backend/editor/utils/path-picker.ts index fdf87af07..81b52500a 100644 --- a/src/backend/editor/utils/path-picker.ts +++ b/src/backend/editor/utils/path-picker.ts @@ -106,11 +106,7 @@ const getPlcopenImportFilePath = async (serviceManager: GetProjectPathProps) => } } -const getPlcopenExportSavePath = async ( - serviceManager: GetProjectPathProps, - defaultFileName: string, - xml: string, -) => { +const getPlcopenExportSavePath = async (serviceManager: GetProjectPathProps, defaultFileName: string, xml: string) => { const { canceled, filePath } = await dialog.showSaveDialog(serviceManager, { title: 'Export PLCopen XML', defaultPath: defaultFileName, diff --git a/src/frontend/components/_organisms/modals/__tests__/confirm-plcopen-import-modal.test.tsx b/src/frontend/components/_organisms/modals/__tests__/confirm-plcopen-import-modal.test.tsx index 8cb66ea02..b7ce102a2 100644 --- a/src/frontend/components/_organisms/modals/__tests__/confirm-plcopen-import-modal.test.tsx +++ b/src/frontend/components/_organisms/modals/__tests__/confirm-plcopen-import-modal.test.tsx @@ -30,7 +30,9 @@ describe('ConfirmPlcopenImportModal', () => { render() expect(screen.getByText('Import PLCopen XML?')).toBeTruthy() expect( - screen.getByText('Importing a PLCopen XML file will overwrite the entire currently open project. This cannot be undone.'), + screen.getByText( + 'Importing a PLCopen XML file will overwrite the entire currently open project. This cannot be undone.', + ), ).toBeTruthy() }) diff --git a/src/frontend/services/__tests__/import-actions.test.ts b/src/frontend/services/__tests__/import-actions.test.ts index 7f37678f6..6be48002a 100644 --- a/src/frontend/services/__tests__/import-actions.test.ts +++ b/src/frontend/services/__tests__/import-actions.test.ts @@ -45,7 +45,11 @@ beforeEach(() => { sharedWorkspaceActions: { handleOpenProjectResponse }, }) mockParsePlcopenXml.mockReturnValue({ - projectData: { dataTypes: [], pous: [], configurations: { resource: { tasks: [], instances: [], globalVariables: [] } } }, + projectData: { + dataTypes: [], + pous: [], + configurations: { resource: { tasks: [], instances: [], globalVariables: [] } }, + }, warnings: [], projectName: 'Imported', }) @@ -84,7 +88,11 @@ describe('executeImportPlcopen', () => { expect(mockParsePlcopenXml).toHaveBeenCalledWith('') expect(handleOpenProjectResponse).toHaveBeenCalledWith({ meta: { name: 'Imported', type: 'plc-project', path: 'proj-1' }, - projectData: { dataTypes: [], pous: [], configurations: { resource: { tasks: [], instances: [], globalVariables: [] } } }, + projectData: { + dataTypes: [], + pous: [], + configurations: { resource: { tasks: [], instances: [], globalVariables: [] } }, + }, warnings: [], }) expect(mockToast).toHaveBeenCalledWith(expect.objectContaining({ variant: 'default' })) @@ -92,7 +100,11 @@ describe('executeImportPlcopen', () => { it('falls back to "Imported Project" when the XML carries no project name', async () => { mockParsePlcopenXml.mockReturnValue({ - projectData: { dataTypes: [], pous: [], configurations: { resource: { tasks: [], instances: [], globalVariables: [] } } }, + projectData: { + dataTypes: [], + pous: [], + configurations: { resource: { tasks: [], instances: [], globalVariables: [] } }, + }, warnings: [], projectName: '', }) @@ -108,7 +120,11 @@ describe('executeImportPlcopen', () => { it('logs warnings to console and shows a warning toast mentioning the count', async () => { const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) mockParsePlcopenXml.mockReturnValue({ - projectData: { dataTypes: [], pous: [], configurations: { resource: { tasks: [], instances: [], globalVariables: [] } } }, + projectData: { + dataTypes: [], + pous: [], + configurations: { resource: { tasks: [], instances: [], globalVariables: [] } }, + }, warnings: ['SFC body dropped', 'Unknown dialect element'], projectName: 'Imported', }) diff --git a/src/frontend/utils/PLC/xml-parser/__tests__/instances-xml.test.ts b/src/frontend/utils/PLC/xml-parser/__tests__/instances-xml.test.ts index db751ce50..e39975264 100644 --- a/src/frontend/utils/PLC/xml-parser/__tests__/instances-xml.test.ts +++ b/src/frontend/utils/PLC/xml-parser/__tests__/instances-xml.test.ts @@ -39,7 +39,14 @@ describe('parseConfigurationXml', () => { }, }) expect(result.resource.globalVariables).toEqual([ - { name: 'gvar', class: 'global', type: { definition: 'base-type', value: 'BOOL' }, location: '', initialValue: null, documentation: '' }, + { + name: 'gvar', + class: 'global', + type: { definition: 'base-type', value: 'BOOL' }, + location: '', + initialValue: null, + documentation: '', + }, ]) }) diff --git a/src/frontend/utils/PLC/xml-parser/__tests__/parse-plcopen-xml.test.ts b/src/frontend/utils/PLC/xml-parser/__tests__/parse-plcopen-xml.test.ts index 690cc216f..1a41f2a29 100644 --- a/src/frontend/utils/PLC/xml-parser/__tests__/parse-plcopen-xml.test.ts +++ b/src/frontend/utils/PLC/xml-parser/__tests__/parse-plcopen-xml.test.ts @@ -74,16 +74,59 @@ const fbdRung = { }, ], edges: [ - { id: 'e1', source: 'iv1', sourceHandle: 'output-variable', target: 'ov1', targetHandle: 'input-variable', type: 'smoothstep' }, + { + id: 'e1', + source: 'iv1', + sourceHandle: 'output-variable', + target: 'ov1', + targetHandle: 'input-variable', + type: 'smoothstep', + }, ], } -const railOutHandle = { id: 'left-rail', type: 'source' as const, position: 'right' as const, glbPosition: { x: 20, y: 20 }, relPosition: { x: 20, y: 20 } } -const contactInHandle = { id: 'input', type: 'target' as const, position: 'left' as const, glbPosition: { x: 50, y: 20 }, relPosition: { x: 0, y: 20 } } -const contactOutHandle = { id: 'output', type: 'source' as const, position: 'right' as const, glbPosition: { x: 90, y: 20 }, relPosition: { x: 40, y: 20 } } -const coilInHandle = { id: 'input', type: 'target' as const, position: 'left' as const, glbPosition: { x: 100, y: 20 }, relPosition: { x: 0, y: 20 } } -const coilOutHandle = { id: 'output', type: 'source' as const, position: 'right' as const, glbPosition: { x: 140, y: 20 }, relPosition: { x: 40, y: 20 } } -const railInHandle = { id: 'right-rail', type: 'target' as const, position: 'left' as const, glbPosition: { x: 150, y: 20 }, relPosition: { x: 0, y: 20 } } +const railOutHandle = { + id: 'left-rail', + type: 'source' as const, + position: 'right' as const, + glbPosition: { x: 20, y: 20 }, + relPosition: { x: 20, y: 20 }, +} +const contactInHandle = { + id: 'input', + type: 'target' as const, + position: 'left' as const, + glbPosition: { x: 50, y: 20 }, + relPosition: { x: 0, y: 20 }, +} +const contactOutHandle = { + id: 'output', + type: 'source' as const, + position: 'right' as const, + glbPosition: { x: 90, y: 20 }, + relPosition: { x: 40, y: 20 }, +} +const coilInHandle = { + id: 'input', + type: 'target' as const, + position: 'left' as const, + glbPosition: { x: 100, y: 20 }, + relPosition: { x: 0, y: 20 }, +} +const coilOutHandle = { + id: 'output', + type: 'source' as const, + position: 'right' as const, + glbPosition: { x: 140, y: 20 }, + relPosition: { x: 40, y: 20 }, +} +const railInHandle = { + id: 'right-rail', + type: 'target' as const, + position: 'left' as const, + glbPosition: { x: 150, y: 20 }, + relPosition: { x: 0, y: 20 }, +} const ladderRung = { id: 'rung-0', @@ -185,7 +228,11 @@ const ladderRung = { function makeFixture(): PLCProjectData { return { dataTypes: [ - { name: 'MyStruct', derivation: 'structure', variable: [{ name: 'flag', type: { definition: 'base-type', value: 'BOOL' } }] }, + { + name: 'MyStruct', + derivation: 'structure', + variable: [{ name: 'flag', type: { definition: 'base-type', value: 'BOOL' } }], + }, { name: 'MyEnum', derivation: 'enumerated', @@ -207,7 +254,13 @@ function makeFixture(): PLCProjectData { name: 'mainSt', language: 'st', variables: [ - { name: 'a', class: 'input', type: { definition: 'base-type', value: 'BOOL' }, location: '', documentation: '' }, + { + name: 'a', + class: 'input', + type: { definition: 'base-type', value: 'BOOL' }, + location: '', + documentation: '', + }, ], body: { language: 'st', value: 'a := TRUE;' }, documentation: 'ST program', @@ -219,7 +272,13 @@ function makeFixture(): PLCProjectData { name: 'mainIl', language: 'il', variables: [ - { name: 'b', class: 'local', type: { definition: 'base-type', value: 'INT' }, location: '', documentation: '' }, + { + name: 'b', + class: 'local', + type: { definition: 'base-type', value: 'INT' }, + location: '', + documentation: '', + }, ], body: { language: 'il', value: 'LD 1' }, documentation: '', @@ -231,8 +290,20 @@ function makeFixture(): PLCProjectData { name: 'mainLd', language: 'ld', variables: [ - { name: 'X1', class: 'input', type: { definition: 'base-type', value: 'BOOL' }, location: '', documentation: '' }, - { name: 'Y1', class: 'output', type: { definition: 'base-type', value: 'BOOL' }, location: '', documentation: '' }, + { + name: 'X1', + class: 'input', + type: { definition: 'base-type', value: 'BOOL' }, + location: '', + documentation: '', + }, + { + name: 'Y1', + class: 'output', + type: { definition: 'base-type', value: 'BOOL' }, + location: '', + documentation: '', + }, ], body: { language: 'ld', value: { name: 'mainLd', rungs: [ladderRung] } }, documentation: '', @@ -244,8 +315,20 @@ function makeFixture(): PLCProjectData { name: 'mainFbd', language: 'fbd', variables: [ - { name: 'X1', class: 'input', type: { definition: 'base-type', value: 'BOOL' }, location: '', documentation: '' }, - { name: 'Y1', class: 'output', type: { definition: 'base-type', value: 'BOOL' }, location: '', documentation: '' }, + { + name: 'X1', + class: 'input', + type: { definition: 'base-type', value: 'BOOL' }, + location: '', + documentation: '', + }, + { + name: 'Y1', + class: 'output', + type: { definition: 'base-type', value: 'BOOL' }, + location: '', + documentation: '', + }, ], body: { language: 'fbd', value: { name: 'mainFbd', rung: fbdRung } }, documentation: '', @@ -257,7 +340,13 @@ function makeFixture(): PLCProjectData { tasks: [{ name: 'task0', triggering: 'Cyclic', interval: 'T#20ms', priority: 0 }], instances: [{ name: 'inst0', task: 'task0', program: 'mainSt' }], globalVariables: [ - { name: 'gvar', class: 'global', type: { definition: 'base-type', value: 'BOOL' }, location: '', documentation: '' }, + { + name: 'gvar', + class: 'global', + type: { definition: 'base-type', value: 'BOOL' }, + location: '', + documentation: '', + }, ], }, }, @@ -305,7 +394,14 @@ describe('parsePlcopenXml — round trip against XmlGenerator (old-editor)', () expect(pou?.pouType).toBe('program') expect(pou?.documentation).toBe('ST program') expect(pou?.interface?.variables).toEqual([ - { name: 'a', class: 'input', type: { definition: 'base-type', value: 'BOOL' }, location: '', initialValue: null, documentation: '' }, + { + name: 'a', + class: 'input', + type: { definition: 'base-type', value: 'BOOL' }, + location: '', + initialValue: null, + documentation: '', + }, ]) expect(pou?.body).toEqual({ language: 'st', value: 'a := TRUE;' }) }) @@ -319,7 +415,11 @@ describe('parsePlcopenXml — round trip against XmlGenerator (old-editor)', () it('recovers the LD program rung: power rails, contact, coil, and their wiring', () => { const pou = result.projectData.pous.find((p) => p.name === 'mainLd') expect(pou?.body.language).toBe('ld') - const ldBody = pou?.body.value as { name: string; updated: boolean; rungs: Array<{ nodes: unknown[]; edges: unknown[] }> } + const ldBody = pou?.body.value as { + name: string + updated: boolean + rungs: Array<{ nodes: unknown[]; edges: unknown[] }> + } expect(ldBody.name).toBe('mainLd') expect(ldBody.updated).toBe(false) expect(ldBody.rungs).toHaveLength(1) @@ -330,7 +430,9 @@ describe('parsePlcopenXml — round trip against XmlGenerator (old-editor)', () ['LEFT-POWER-RAIL-1', 'RIGHT-POWER-RAIL-4', 'CONTACT-2', 'COIL-3'].sort(), ) expect(rung.edges).toHaveLength(3) - const nodesById = new Map((rung.nodes as Array<{ id: string; data: { variable: { name: string } } }>).map((n) => [n.id, n])) + const nodesById = new Map( + (rung.nodes as Array<{ id: string; data: { variable: { name: string } } }>).map((n) => [n.id, n]), + ) const contactNode = nodesById.get('CONTACT-2') as { data: { variable: { name: string } } } const coilNode = nodesById.get('COIL-3') as { data: { variable: { name: string } } } expect(contactNode.data.variable).toEqual({ name: 'X1' }) @@ -359,7 +461,14 @@ describe('parsePlcopenXml — round trip against XmlGenerator (old-editor)', () expect(resource.tasks).toEqual([{ name: 'task0', triggering: 'Cyclic', interval: 'T#20ms', priority: 0 }]) expect(resource.instances).toEqual([{ name: 'inst0', task: 'task0', program: 'mainSt' }]) expect(resource.globalVariables).toEqual([ - { name: 'gvar', class: 'global', type: { definition: 'base-type', value: 'BOOL' }, location: '', initialValue: null, documentation: '' }, + { + name: 'gvar', + class: 'global', + type: { definition: 'base-type', value: 'BOOL' }, + location: '', + initialValue: null, + documentation: '', + }, ]) }) }) @@ -390,7 +499,9 @@ describe('parsePlcopenXml — dialect scope', () => { it('produces a warning (does not throw) for an SFC body', () => { const result = parsePlcopenXml(baseXml('')) expect(result.projectData.pous).toEqual([]) - expect(result.warnings).toEqual(['POU "unsupported": Sequential Function Chart is not supported by the importer, skipped']) + expect(result.warnings).toEqual([ + 'POU "unsupported": Sequential Function Chart is not supported by the importer, skipped', + ]) }) it('produces a warning (does not throw) for a body shape outside the old-editor dialect', () => { @@ -452,6 +563,8 @@ describe('parsePlcopenXml — malformed connection reference', () => { const pou = result.projectData.pous.find((p) => p.name === 'dangling') const fbdBody = pou?.body.value as { rung: { edges: unknown[] } } expect(fbdBody.rung.edges).toEqual([]) - expect(result.warnings).toEqual(['POU "dangling": FBD connection references unknown localId "doesnotexist", skipped']) + expect(result.warnings).toEqual([ + 'POU "dangling": FBD connection references unknown localId "doesnotexist", skipped', + ]) }) }) diff --git a/src/frontend/utils/PLC/xml-parser/language/__tests__/ladder-xml.test.ts b/src/frontend/utils/PLC/xml-parser/language/__tests__/ladder-xml.test.ts index 66c750fd0..4aa7d400c 100644 --- a/src/frontend/utils/PLC/xml-parser/language/__tests__/ladder-xml.test.ts +++ b/src/frontend/utils/PLC/xml-parser/language/__tests__/ladder-xml.test.ts @@ -27,7 +27,10 @@ describe('parseLadderXml', () => { '@width': '40', '@height': '40', position: { '@x': '50', '@y': '0' }, - connectionPointIn: { relPosition: { '@x': '0', '@y': '20' }, connection: [{ '@refLocalId': '1', '@formalParameter': 'left-rail' }] }, + connectionPointIn: { + relPosition: { '@x': '0', '@y': '20' }, + connection: [{ '@refLocalId': '1', '@formalParameter': 'left-rail' }], + }, connectionPointOut: { relPosition: { '@x': '40', '@y': '20' } }, variable: ['X1'], }, @@ -39,7 +42,10 @@ describe('parseLadderXml', () => { '@width': '40', '@height': '40', position: { '@x': '100', '@y': '0' }, - connectionPointIn: { relPosition: { '@x': '0', '@y': '20' }, connection: [{ '@refLocalId': '2', '@formalParameter': 'output' }] }, + connectionPointIn: { + relPosition: { '@x': '0', '@y': '20' }, + connection: [{ '@refLocalId': '2', '@formalParameter': 'output' }], + }, connectionPointOut: { relPosition: { '@x': '40', '@y': '20' } }, variable: ['Y1'], }, @@ -50,7 +56,10 @@ describe('parseLadderXml', () => { '@width': '20', '@height': '40', position: { '@x': '150', '@y': '0' }, - connectionPointIn: { relPosition: { '@x': '0', '@y': '20' }, connection: [{ '@refLocalId': '3', '@formalParameter': 'output' }] }, + connectionPointIn: { + relPosition: { '@x': '0', '@y': '20' }, + connection: [{ '@refLocalId': '3', '@formalParameter': 'output' }], + }, }, ], }) @@ -61,22 +70,13 @@ describe('parseLadderXml', () => { // Node order within a rung follows the raw XML's element-type grouping // (leftPowerRail, rightPowerRail, contact, coil, ...) — see // parseLadderXml's node-collection loop — not rung/visual position. - expect(rung.nodes.map((n) => n.id)).toEqual([ - 'LEFT-POWER-RAIL-1', - 'RIGHT-POWER-RAIL-4', - 'CONTACT-2', - 'COIL-3', - ]) + expect(rung.nodes.map((n) => n.id)).toEqual(['LEFT-POWER-RAIL-1', 'RIGHT-POWER-RAIL-4', 'CONTACT-2', 'COIL-3']) // Edge order follows pendingEdges collection order (grouped by the // consuming node's XML element type), not visual left-to-right order — // compare as a set of {source,target} pairs instead of an exact sequence. expect(rung.edges).toHaveLength(3) expect(rung.edges.map((e) => `${e.source}->${e.target}`).sort()).toEqual( - [ - 'LEFT-POWER-RAIL-1->CONTACT-2', - 'CONTACT-2->COIL-3', - 'COIL-3->RIGHT-POWER-RAIL-4', - ].sort(), + ['LEFT-POWER-RAIL-1->CONTACT-2', 'CONTACT-2->COIL-3', 'COIL-3->RIGHT-POWER-RAIL-4'].sort(), ) expect(rung.edges.every((e) => e.type === 'smoothstep')).toBe(true) expect((rung.nodes[2].data as { variable: { name: string } }).variable).toEqual({ name: 'X1' }) @@ -271,7 +271,11 @@ describe('parseLadderXml', () => { // to the block/outVariable component) — search across all rungs. const allNodes = body.rungs.flatMap((r) => r.nodes) const outVarNode = allNodes.find((n) => n.id === 'OUTPUT-VARIABLE-3') - expect(outVarNode?.data.block).toEqual({ id: '', handleId: 'OUT', variableType: { name: '', class: '', type: { definition: 'base-type', value: '' } } }) + expect(outVarNode?.data.block).toEqual({ + id: '', + handleId: 'OUT', + variableType: { name: '', class: '', type: { definition: 'base-type', value: '' } }, + }) const inVarNode = allNodes.find((n) => n.id === 'INPUT-VARIABLE-2') expect(inVarNode?.data.variable).toEqual({ name: 'LIT1' }) }) diff --git a/src/frontend/utils/PLC/xml-parser/language/ladder-xml.ts b/src/frontend/utils/PLC/xml-parser/language/ladder-xml.ts index 51cf472be..b0799239d 100644 --- a/src/frontend/utils/PLC/xml-parser/language/ladder-xml.ts +++ b/src/frontend/utils/PLC/xml-parser/language/ladder-xml.ts @@ -151,7 +151,9 @@ function parseRightRailXml(entry: Record): { node: PowerRailNod // nothing in the XML shape prevents a foreign document from setting more // than one — priority storage > negated > edge is an arbitrary, documented // call for that (currently unseen-in-fixtures) case. -function parseCoilVariant(entry: Record): 'default' | 'negated' | 'risingEdge' | 'fallingEdge' | 'set' | 'reset' { +function parseCoilVariant( + entry: Record, +): 'default' | 'negated' | 'risingEdge' | 'fallingEdge' | 'set' | 'reset' { const storage = entry['@storage'] if (storage === 'set') return 'set' if (storage === 'reset') return 'reset' @@ -368,7 +370,11 @@ function parseInVariableXml(entry: Record): VariableNode { // the block's own entry names its source by // refLocalId, not the reverse) — left as an honest placeholder; the // edge built from that block's connection is the source of truth. - block: { id: '', handleId: '', variableType: { name: '', class: '', type: { definition: 'base-type', value: '' } } }, + block: { + id: '', + handleId: '', + variableType: { name: '', class: '', type: { definition: 'base-type', value: '' } }, + }, }, } } @@ -511,9 +517,7 @@ export function parseLadderXml(pouName: string, ldXml: unknown): { body: LadderF const targetNodeId = nodeIdByNumericId.get(pending.targetNumericId) const sourceNodeId = nodeIdByNumericId.get(pending.sourceRefLocalId) if (!targetNodeId || !sourceNodeId) { - warnings.push( - `POU "${pouName}": LD connection references unknown localId "${pending.sourceRefLocalId}", skipped`, - ) + warnings.push(`POU "${pouName}": LD connection references unknown localId "${pending.sourceRefLocalId}", skipped`) continue } const sourceHandle = pending.sourceFormalParameter ?? LEAF_OUTPUT_HANDLE diff --git a/src/frontend/utils/PLC/xml-parser/variable-xml.ts b/src/frontend/utils/PLC/xml-parser/variable-xml.ts index c3c9210ca..a55273ca6 100644 --- a/src/frontend/utils/PLC/xml-parser/variable-xml.ts +++ b/src/frontend/utils/PLC/xml-parser/variable-xml.ts @@ -31,7 +31,7 @@ export function parseVariableXml(varXml: unknown, variableClass: VariableClass): const v = asRecord(varXml) const initialValueXml = asRecord(v.initialValue) const simpleValue = asRecord(initialValueXml.simpleValue) - const initialValue = typeof simpleValue['@value'] === 'string' ? (simpleValue['@value']) : null + const initialValue = typeof simpleValue['@value'] === 'string' ? simpleValue['@value'] : null const location = v['@address'] return { diff --git a/src/frontend/utils/__tests__/iec-types-registry.test.ts b/src/frontend/utils/__tests__/iec-types-registry.test.ts index 589c7e0ee..329453b4b 100644 --- a/src/frontend/utils/__tests__/iec-types-registry.test.ts +++ b/src/frontend/utils/__tests__/iec-types-registry.test.ts @@ -1,4 +1,10 @@ -import { BASE_TYPE_NAMES, IEC_BASE_TYPES, isBaseTypeName, lookupBaseType, lookupBaseTypeByXmlElement } from '../iec-types-registry' +import { + BASE_TYPE_NAMES, + IEC_BASE_TYPES, + isBaseTypeName, + lookupBaseType, + lookupBaseTypeByXmlElement, +} from '../iec-types-registry' describe('iec-types-registry', () => { describe('IEC_BASE_TYPES', () => {