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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion jest.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,13 @@
"url": "http://localhost/"
},
"testMatch": ["<rootDir>/src/**/?(*.)+(spec|test).(ts|tsx)", "<rootDir>/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"
],
Comment thread
dcoutinho1328 marked this conversation as resolved.
"transformIgnorePatterns": ["node_modules/(?!strucpp)"],
"transform": {
"\\.(ts|tsx|js|jsx)$": [
Expand Down
7 changes: 7 additions & 0 deletions src/__architecture__/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,13 @@ const KNOWN_EXCEPTIONS: Record<string, LayerName[]> = {
'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'],
}

// ---------------------------------------------------------------------------
Expand Down
132 changes: 132 additions & 0 deletions src/backend/editor/utils/__tests__/path-picker.test.ts
Original file line number Diff line number Diff line change
@@ -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, '<project/>')
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: '<project/>' })
})

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', '<project/>')

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('<project/>')
})

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', '<project/>')

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', '<project/>')

expect(result).toEqual({
success: false,
error: { title: 'Error writing file', description: 'Failed to write the PLCopen XML file.' },
})
})
})
64 changes: 63 additions & 1 deletion src/backend/editor/utils/path-picker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,4 +74,66 @@ 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 }
16 changes: 12 additions & 4 deletions src/frontend/components/_molecules/menu-bar/menus/file.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import * as MenuPrimitive from '@radix-ui/react-menubar'
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'
Expand Down Expand Up @@ -83,12 +84,19 @@
</MenuPrimitive.Item>
</>
)}
{capabilities.hasProjectExport && (
{(capabilities.hasProjectExport || capabilities.hasProjectImport) && (
<>
<MenuPrimitive.Separator className={SEPARATOR} />
<MenuPrimitive.Item className={ITEM} disabled>
<span>{i18n.t('menu:file.submenu.exportToPLCOpenXml')}</span>
</MenuPrimitive.Item>
{capabilities.hasProjectExport && (
<MenuPrimitive.Item className={ITEM} onClick={() => void executeExportPlcopen(projectPort)}>
<span>{i18n.t('menu:file.submenu.exportToPLCOpenXml')}</span>
</MenuPrimitive.Item>
)}
{capabilities.hasProjectImport && (
<MenuPrimitive.Item className={ITEM} onClick={() => openModal('confirm-plcopen-import')}>
<span>{i18n.t('menu:file.submenu.importFromPLCOpenXml')}</span>
</MenuPrimitive.Item>
)}
</>
)}
<MenuPrimitive.Separator className={SEPARATOR} />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
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(<ConfirmPlcopenImportModal isOpen />)
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(<ConfirmPlcopenImportModal isOpen={false} />)
expect(screen.queryByText('Import PLCopen XML?')).toBeNull()
})

it('calls executeImportPlcopen with the project port and closes the modal on confirm', async () => {
render(<ConfirmPlcopenImportModal isOpen />)

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(<ConfirmPlcopenImportModal isOpen />)

fireEvent.click(screen.getByText('Cancel'))

expect(mockExecuteImportPlcopen).not.toHaveBeenCalled()
expect(closeModal).toHaveBeenCalledTimes(1)
})
})
Original file line number Diff line number Diff line change
@@ -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 (
<Modal
open={isOpen}
onOpenChange={(open) => {
if (!open) closeModal()
onOpenChange('confirm-plcopen-import', open)
}}
{...rest}
>
<ModalContent className='flex max-h-96 w-[340px] select-none flex-col items-center justify-evenly rounded-lg'>
<div className='flex select-none flex-col items-center gap-5'>
<WarningIcon className='mt-2 h-[73px] w-[73px]' />
<div className='flex flex-col gap-2'>
<p className='text-m w-full text-center font-bold text-gray-600 dark:text-neutral-100'>
Import PLCopen XML?
</p>
<p className='w-full text-center text-xs text-gray-500 dark:text-neutral-400'>
Importing a PLCopen XML file will overwrite the entire currently open project. This cannot be undone.
</p>
</div>
<div className='flex w-[220px] flex-col gap-1 space-y-2 text-sm'>
<button
onClick={() => void handleConfirm()}
className='w-full rounded-lg bg-brand px-4 py-2 text-center font-medium text-white'
>
Import PLCopen XML
</button>
<button
onClick={() => closeModal()}
className='w-full rounded-md bg-neutral-100 px-4 py-2 font-medium dark:bg-neutral-850 dark:text-neutral-100'
>
Cancel
</button>
</div>
</div>
</ModalContent>
</Modal>
)
}

export { ConfirmPlcopenImportModal }
Loading
Loading