From 0d6ab31f0a139a61b71864106e5899bd18a82342 Mon Sep 17 00:00:00 2001 From: Rudi Date: Thu, 24 Sep 2026 12:44:50 +0000 Subject: [PATCH] Connection storage --- .../nightmare/j3270/storage/HostStorage.java | 262 ++++++++++++++ .../nightmare/j3270/ui/SettingsDialog.java | 141 +++++++- .../storage/ConnectionIniStorageTest.java | 342 ++++++++++++++++++ 3 files changed, 736 insertions(+), 9 deletions(-) create mode 100644 j3270/src/test/java/haus/nightmare/j3270/storage/ConnectionIniStorageTest.java diff --git a/j3270/src/main/java/haus/nightmare/j3270/storage/HostStorage.java b/j3270/src/main/java/haus/nightmare/j3270/storage/HostStorage.java index 30a1b16..5090c52 100644 --- a/j3270/src/main/java/haus/nightmare/j3270/storage/HostStorage.java +++ b/j3270/src/main/java/haus/nightmare/j3270/storage/HostStorage.java @@ -1,5 +1,6 @@ package haus.nightmare.j3270.storage; +import java.io.*; import java.util.*; import java.util.logging.Level; import java.util.logging.Logger; @@ -109,6 +110,267 @@ public class HostStorage { } } + // ================= INI File Export / Import ================= + + /** + * Export all currently saved host connections to an INI file. + */ + public static synchronized void exportToIniFile(File file) throws IOException { + List hosts = getSavedHosts(); + exportToIniFile(file, hosts); + } + + public static synchronized void exportToIniFile(String path) throws IOException { + exportToIniFile(new File(path)); + } + + /** + * Export a specified list of host connections to an INI file. + */ + public static void exportToIniFile(File file, List hosts) throws IOException { + if (file == null) { + throw new IllegalArgumentException("Target file cannot be null"); + } + File parent = file.getParentFile(); + if (parent != null && !parent.exists()) { + parent.mkdirs(); + } + try (PrintWriter w = new PrintWriter(new BufferedWriter(new FileWriter(file)))) { + w.println("; j3270 Saved Connections Configuration File"); + w.println("; Exported on " + new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new java.util.Date())); + w.println("; Connections count: " + (hosts != null ? hosts.size() : 0)); + w.println(); + + if (hosts != null) { + for (SavedHost h : hosts) { + String section = (h.getName() != null && !h.getName().trim().isEmpty()) + ? h.getName().trim() + : (h.getHost() != null && !h.getHost().trim().isEmpty() ? h.getHost().trim() : "Connection"); + w.println("[" + section + "]"); + if (h.getId() != null && !h.getId().trim().isEmpty()) { + w.println("id = " + h.getId()); + } + w.println("name = " + (h.getName() != null ? h.getName() : "")); + w.println("host = " + (h.getHost() != null ? h.getHost() : "")); + w.println("port = " + h.getPort()); + w.println("model = " + h.getModel()); + w.println("dynamicRows = " + h.getDynamicRows()); + w.println("dynamicCols = " + h.getDynamicCols()); + w.println("luName = " + (h.getLuName() != null ? h.getLuName() : "")); + w.println("autoConnect = " + h.isAutoConnect()); + w.println("hostType = " + (h.getHostType() != null ? h.getHostType() : "TSO")); + w.println("useTls = " + h.isUseTls()); + w.println("tlsVerifyCert = " + h.isTlsVerifyCert()); + w.println("tn3270e = " + h.isTn3270e()); + w.println("graphicsMode = " + (h.getGraphicsMode() != null ? h.getGraphicsMode() : "BOTH")); + w.println("codePage = " + (h.getCodePage() != null ? h.getCodePage() : "037")); + w.println("proxyType = " + (h.getProxyType() != null ? h.getProxyType() : "NONE")); + w.println("proxyHost = " + (h.getProxyHost() != null ? h.getProxyHost() : "")); + w.println("proxyPort = " + h.getProxyPort()); + w.println("proxyUsername = " + (h.getProxyUsername() != null ? h.getProxyUsername() : "")); + w.println("proxyPassword = " + (h.getProxyPassword() != null ? h.getProxyPassword() : "")); + w.println(); + } + } + } + log.info("Saved connections exported to: " + file.getAbsolutePath()); + } + + /** + * Load connections from an INI file without saving them to persistent storage. + */ + public static List loadFromIniFile(File file) throws IOException { + if (file == null || !file.exists()) { + throw new FileNotFoundException("Connections file not found: " + (file != null ? file.getAbsolutePath() : "null")); + } + List hosts = new ArrayList<>(); + try (BufferedReader reader = new BufferedReader(new FileReader(file))) { + String line; + SavedHost current = null; + while ((line = reader.readLine()) != null) { + line = line.trim(); + if (line.isEmpty() || line.startsWith(";") || line.startsWith("#")) { + continue; + } + if (line.startsWith("[") && line.endsWith("]")) { + if (current != null) { + hosts.add(current); + } + String section = line.substring(1, line.length() - 1).trim(); + if (section.toLowerCase().startsWith("connection:")) { + section = section.substring("connection:".length()).trim(); + } else if (section.toLowerCase().startsWith("host:")) { + section = section.substring("host:".length()).trim(); + } + current = new SavedHost(); + current.setName(section); + continue; + } + int eq = line.indexOf('='); + if (eq < 0) { + continue; + } + String key = line.substring(0, eq).trim(); + String value = line.substring(eq + 1).trim(); + if (current == null) { + current = new SavedHost(); + current.setName("Default"); + } + applyIniEntry(current, key, value); + } + if (current != null) { + hosts.add(current); + } + } + return hosts; + } + + public static List loadFromIniFile(String path) throws IOException { + return loadFromIniFile(new File(path)); + } + + /** + * Import connections from an INI file and merge them into persistent storage. + * Updates existing profiles matching by ID or Name, and appends new profiles. + */ + public static synchronized List importFromIniFile(File file) throws IOException { + List imported = loadFromIniFile(file); + if (imported == null || imported.isEmpty()) { + return getSavedHosts(); + } + List current = new ArrayList<>(getSavedHosts()); + for (SavedHost host : imported) { + int index = -1; + for (int i = 0; i < current.size(); i++) { + SavedHost existing = current.get(i); + if ((host.getId() != null && !host.getId().trim().isEmpty() && Objects.equals(existing.getId(), host.getId())) || + (host.getName() != null && !host.getName().trim().isEmpty() && + host.getName().equalsIgnoreCase(existing.getName()))) { + index = i; + break; + } + } + if (host.isAutoConnect()) { + for (SavedHost c : current) { + c.setAutoConnect(false); + } + } + if (index >= 0) { + if (host.getId() == null || host.getId().trim().isEmpty()) { + host.setId(current.get(index).getId()); + } + current.set(index, host); + } else { + current.add(host); + } + } + saveAll(current); + return current; + } + + public static synchronized List importFromIniFile(String path) throws IOException { + return importFromIniFile(new File(path)); + } + + private static void applyIniEntry(SavedHost h, String key, String value) { + String lowerKey = key.toLowerCase(); + switch (lowerKey) { + case "id": + if (!value.isEmpty()) h.setId(value); + break; + case "name": + case "profilename": + case "profile": + if (!value.isEmpty()) h.setName(value); + break; + case "host": + case "hostname": + case "address": + h.setHost(value); + break; + case "port": + h.setPort(parseInt(value, 23)); + break; + case "model": + case "terminalmodel": + h.setModel(parseInt(value, 4)); + break; + case "dynamicrows": + case "dynamic_rows": + case "rows": + h.setDynamicRows(parseInt(value, 62)); + break; + case "dynamiccols": + case "dynamic_cols": + case "cols": + h.setDynamicCols(parseInt(value, 160)); + break; + case "luname": + case "lu_name": + case "lu": + h.setLuName(value); + break; + case "autoconnect": + case "auto_connect": + h.setAutoConnect(Boolean.parseBoolean(value)); + break; + case "hosttype": + case "host_type": + case "type": + h.setHostType(value.toUpperCase()); + break; + case "usetls": + case "use_tls": + case "tls": + case "ssl": + h.setUseTls(Boolean.parseBoolean(value)); + break; + case "tlsverifycert": + case "verifycert": + case "tls_verify_cert": + h.setTlsVerifyCert(Boolean.parseBoolean(value)); + break; + case "tn3270e": + case "enabletn3270e": + h.setTn3270e(Boolean.parseBoolean(value)); + break; + case "graphicsmode": + case "graphics_mode": + case "graphics": + h.setGraphicsMode(value.toUpperCase()); + break; + case "codepage": + case "code_page": + case "charset": + h.setCodePage(value); + break; + case "proxytype": + case "proxy_type": + h.setProxyType(value.toUpperCase()); + break; + case "proxyhost": + case "proxy_host": + h.setProxyHost(value); + break; + case "proxyport": + case "proxy_port": + h.setProxyPort(parseInt(value, 0)); + break; + case "proxyusername": + case "proxy_username": + case "proxyuser": + h.setProxyUsername(value); + break; + case "proxypassword": + case "proxy_password": + case "proxypass": + h.setProxyPassword(value); + break; + default: + break; + } + } + // ================= JSON Serialization ================= public static String serializeHostsJson(List hosts) { diff --git a/j3270/src/main/java/haus/nightmare/j3270/ui/SettingsDialog.java b/j3270/src/main/java/haus/nightmare/j3270/ui/SettingsDialog.java index 78cf85d..54b8a41 100644 --- a/j3270/src/main/java/haus/nightmare/j3270/ui/SettingsDialog.java +++ b/j3270/src/main/java/haus/nightmare/j3270/ui/SettingsDialog.java @@ -86,6 +86,8 @@ public class SettingsDialog extends JDialog { // Bottom action buttons private JButton btnImport; private JButton btnExport; + private JButton btnImportConnections; + private JButton btnExportConnections; // Colors tab component tracking private final JPanel[] hostColorSwatches = new JPanel[16]; @@ -97,7 +99,7 @@ public class SettingsDialog extends JDialog { AppIcon.applyTo(this); initComponents(); - setSize(660, 540); + setSize(680, 650); setLocationRelativeTo(parent); } @@ -110,13 +112,18 @@ public class SettingsDialog extends JDialog { tabbedPane.addTab("Connection", createConnectionPanel()); tabbedPane.addTab("Entry Assist", createEntryAssistPanel()); - JPanel buttonPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT, 8, 8)); btnImport = new JButton("Import Config..."); ThemeManager.styleButton(btnImport, ThemeManager.ButtonVariant.DEFAULT); btnExport = new JButton("Export Config..."); ThemeManager.styleButton(btnExport, ThemeManager.ButtonVariant.DEFAULT); + btnImportConnections = new JButton("Import Connections..."); + ThemeManager.styleButton(btnImportConnections, ThemeManager.ButtonVariant.DEFAULT); + + btnExportConnections = new JButton("Export Connections..."); + ThemeManager.styleButton(btnExportConnections, ThemeManager.ButtonVariant.DEFAULT); + JButton btnApply = new JButton("Apply"); ThemeManager.styleButton(btnApply, ThemeManager.ButtonVariant.DEFAULT); @@ -134,6 +141,14 @@ public class SettingsDialog extends JDialog { exportConfig(); }); + btnImportConnections.addActionListener((ActionEvent e) -> { + importConnections(); + }); + + btnExportConnections.addActionListener((ActionEvent e) -> { + exportConnections(); + }); + btnOk.addActionListener((ActionEvent e) -> { boolean success = applySettings(); if (success) { @@ -149,12 +164,28 @@ public class SettingsDialog extends JDialog { dispose(); }); - buttonPanel.add(btnImport); - buttonPanel.add(btnExport); - buttonPanel.add(Box.createHorizontalStrut(20)); - buttonPanel.add(btnApply); - buttonPanel.add(btnCancel); - buttonPanel.add(btnOk); + JPanel buttonPanel = new JPanel(new GridLayout(3, 1, 0, 4)); + buttonPanel.setBorder(BorderFactory.createEmptyBorder(4, 8, 8, 8)); + + JPanel row1 = new JPanel(new FlowLayout(FlowLayout.RIGHT, 6, 2)); + row1.setOpaque(false); + row1.add(btnImport); + row1.add(btnExport); + + JPanel row2 = new JPanel(new FlowLayout(FlowLayout.RIGHT, 6, 2)); + row2.setOpaque(false); + row2.add(btnImportConnections); + row2.add(btnExportConnections); + + JPanel row3 = new JPanel(new FlowLayout(FlowLayout.RIGHT, 6, 2)); + row3.setOpaque(false); + row3.add(btnApply); + row3.add(btnCancel); + row3.add(btnOk); + + buttonPanel.add(row1); + buttonPanel.add(row2); + buttonPanel.add(row3); getContentPane().add(tabbedPane, BorderLayout.CENTER); getContentPane().add(buttonPanel, BorderLayout.SOUTH); @@ -1510,7 +1541,7 @@ public class SettingsDialog extends JDialog { // ========== Import Config ========== - void importConfigFile(File file) throws IOException { + public void importConfigFile(File file) throws IOException { if (file == null || !file.exists()) { throw new FileNotFoundException("Config file not found: " + (file != null ? file.getAbsolutePath() : "null")); } @@ -1548,10 +1579,102 @@ public class SettingsDialog extends JDialog { return btnExport; } + public JButton getBtnImportConnections() { + return btnImportConnections; + } + + public JButton getBtnExportConnections() { + return btnExportConnections; + } + public JComboBox getSavedHostBox() { return savedHostBox; } + // ========== Import / Export Connections ========== + + public void importConnectionsFile(File file) throws IOException { + if (file == null || !file.exists()) { + throw new FileNotFoundException("Connections file not found: " + (file != null ? file.getAbsolutePath() : "null")); + } + HostStorage.importFromIniFile(file); + refreshSavedHostsBox(); + SavedHost autoHost = HostStorage.getAutoConnectHost(); + if (autoHost != null) { + Settings.setAutoConnectHost(autoHost.getHost()); + Settings.setAutoConnectPort(autoHost.getPort()); + Settings.setAutoConnectTls(autoHost.isUseTls()); + Settings.setAutoConnectVerifyCert(autoHost.isTlsVerifyCert()); + Settings.setAutoConnectTn3270e(autoHost.isTn3270e()); + } + refreshFromSettings(); + } + + public void exportConnectionsFile(File file) throws IOException { + if (file == null) { + throw new IllegalArgumentException("Target file cannot be null"); + } + HostStorage.exportToIniFile(file); + } + + private void importConnections() { + JFileChooser fc = new JFileChooser(); + fc.setDialogTitle("Import Connections"); + fc.setSelectedFile(new java.io.File("connections.ini")); + fc.setFileFilter(new javax.swing.filechooser.FileNameExtensionFilter("INI Files (*.ini)", "ini")); + + int result = fc.showOpenDialog(this); + if (result == JFileChooser.APPROVE_OPTION) { + java.io.File file = fc.getSelectedFile(); + try { + importConnectionsFile(file); + int count = HostStorage.getSavedHosts().size(); + JOptionPane.showMessageDialog(this, + "Connections imported successfully from:\n" + file.getAbsolutePath() + "\nTotal saved connections: " + count, + "Import Successful", JOptionPane.INFORMATION_MESSAGE); + } catch (Exception ex) { + JOptionPane.showMessageDialog(this, + "Import connections failed: " + ex.getMessage(), + "Import Error", JOptionPane.ERROR_MESSAGE); + } + } + } + + private void exportConnections() { + JFileChooser fc = new JFileChooser(); + fc.setDialogTitle("Export Connections"); + fc.setSelectedFile(new java.io.File("connections.ini")); + fc.setFileFilter(new javax.swing.filechooser.FileNameExtensionFilter("INI Files (*.ini)", "ini")); + + int result = fc.showSaveDialog(this); + if (result == JFileChooser.APPROVE_OPTION) { + java.io.File file = fc.getSelectedFile(); + // Ensure .ini extension + if (!file.getName().toLowerCase().endsWith(".ini")) { + file = new java.io.File(file.getAbsolutePath() + ".ini"); + } + + // Confirm overwrite + if (file.exists()) { + int confirm = JOptionPane.showConfirmDialog(this, + "File already exists. Overwrite?", "Confirm Overwrite", + JOptionPane.YES_NO_OPTION); + if (confirm != JOptionPane.YES_OPTION) return; + } + + try { + exportConnectionsFile(file); + JOptionPane.showMessageDialog(this, + "Connections exported to:\n" + file.getAbsolutePath(), + "Export Successful", JOptionPane.INFORMATION_MESSAGE); + } catch (Exception ex) { + JOptionPane.showMessageDialog(this, + "Export connections failed: " + ex.getMessage(), + "Export Error", JOptionPane.ERROR_MESSAGE); + } + } + } + // ========== Export Config ========== private void exportConfig() { diff --git a/j3270/src/test/java/haus/nightmare/j3270/storage/ConnectionIniStorageTest.java b/j3270/src/test/java/haus/nightmare/j3270/storage/ConnectionIniStorageTest.java new file mode 100644 index 0000000..7919c7d --- /dev/null +++ b/j3270/src/test/java/haus/nightmare/j3270/storage/ConnectionIniStorageTest.java @@ -0,0 +1,342 @@ +package haus.nightmare.j3270.storage; + +import haus.nightmare.j3270.J3270App; +import haus.nightmare.j3270.config.Settings; +import haus.nightmare.j3270.ui.SettingsDialog; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import javax.swing.*; +import java.awt.*; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileWriter; +import java.io.IOException; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +public class ConnectionIniStorageTest { + + private List originalSavedHosts; + private String origHost; + private int origPort; + + @BeforeEach + public void setUp() { + originalSavedHosts = HostStorage.getSavedHosts(); + origHost = Settings.getAutoConnectHost(); + origPort = Settings.getAutoConnectPort(); + } + + @AfterEach + public void tearDown() { + if (originalSavedHosts != null) { + HostStorage.saveAll(originalSavedHosts); + } else { + HostStorage.clearAll(); + } + Settings.setAutoConnectHost(origHost); + Settings.setAutoConnectPort(origPort); + } + + @Test + @DisplayName("HostStorage exports to and loads from connections.ini with full fidelity") + public void testExportAndLoadConnectionsIniRoundtrip() throws IOException { + List hosts = new ArrayList<>(); + SavedHost h1 = new SavedHost("id-mainframe-1", "Production TSO", "mainframe.corp.com", 23, 4, 62, 160, + "LU01", true, "TSO", false, true, true, "BOTH", "037", "NONE", "", 0, "", ""); + SavedHost h2 = new SavedHost("id-vm-2", "VM CMS Test", "vmcms.lab.net", 992, 2, 24, 80, + "LUCMS", false, "CMS", true, false, true, "GDDM", "1047", "SOCKS5", "10.0.0.1", 1080, "proxyuser", "proxypass"); + hosts.add(h1); + hosts.add(h2); + + File tmpIni = File.createTempFile("connections_test", ".ini"); + tmpIni.deleteOnExit(); + + try { + HostStorage.exportToIniFile(tmpIni, hosts); + + assertTrue(tmpIni.exists()); + String content = new String(Files.readAllBytes(tmpIni.toPath())); + assertTrue(content.contains("[Production TSO]"), "Must contain section header [Production TSO]"); + assertTrue(content.contains("[VM CMS Test]"), "Must contain section header [VM CMS Test]"); + assertTrue(content.contains("host = mainframe.corp.com")); + assertTrue(content.contains("proxyHost = 10.0.0.1")); + assertTrue(content.contains("proxyType = SOCKS5")); + + List loaded = HostStorage.loadFromIniFile(tmpIni); + assertEquals(2, loaded.size(), "Should load 2 connections from INI"); + + SavedHost loaded1 = loaded.get(0); + assertEquals("id-mainframe-1", loaded1.getId()); + assertEquals("Production TSO", loaded1.getName()); + assertEquals("mainframe.corp.com", loaded1.getHost()); + assertEquals(23, loaded1.getPort()); + assertEquals(4, loaded1.getModel()); + assertEquals(62, loaded1.getDynamicRows()); + assertEquals(160, loaded1.getDynamicCols()); + assertEquals("LU01", loaded1.getLuName()); + assertTrue(loaded1.isAutoConnect()); + assertEquals("TSO", loaded1.getHostType()); + assertFalse(loaded1.isUseTls()); + assertTrue(loaded1.isTlsVerifyCert()); + + SavedHost loaded2 = loaded.get(1); + assertEquals("id-vm-2", loaded2.getId()); + assertEquals("VM CMS Test", loaded2.getName()); + assertEquals("vmcms.lab.net", loaded2.getHost()); + assertEquals(992, loaded2.getPort()); + assertEquals(2, loaded2.getModel()); + assertEquals(24, loaded2.getDynamicRows()); + assertEquals(80, loaded2.getDynamicCols()); + assertEquals("LUCMS", loaded2.getLuName()); + assertFalse(loaded2.isAutoConnect()); + assertEquals("CMS", loaded2.getHostType()); + assertTrue(loaded2.isUseTls()); + assertFalse(loaded2.isTlsVerifyCert()); + assertEquals("SOCKS5", loaded2.getProxyType()); + assertEquals("10.0.0.1", loaded2.getProxyHost()); + assertEquals(1080, loaded2.getProxyPort()); + assertEquals("proxyuser", loaded2.getProxyUsername()); + assertEquals("proxypass", loaded2.getProxyPassword()); + } finally { + if (tmpIni.exists()) tmpIni.delete(); + } + } + + @Test + @DisplayName("HostStorage importFromIniFile merges and updates existing hosts by name or id") + public void testImportFromIniFileMergesAndUpdates() throws IOException { + SavedHost initial = new SavedHost("id-100", "Mainframe Prod", "old.host.org", 23, 4, 62, 160, + "", false, "TSO", false, true, true, "BOTH", "037", "NONE", "", 0, "", ""); + List initList = new ArrayList<>(); + initList.add(initial); + HostStorage.saveAll(initList); + + File tmpIni = File.createTempFile("connections_merge", ".ini"); + tmpIni.deleteOnExit(); + + try { + try (FileWriter w = new FileWriter(tmpIni)) { + // Update Mainframe Prod with new host address + w.write("[Mainframe Prod]\n"); + w.write("id = id-100\n"); + w.write("host = new.host.org\n"); + w.write("port = 2323\n"); + w.write("\n"); + // Add a second connection + w.write("[Development CICS]\n"); + w.write("host = cics.dev.org\n"); + w.write("port = 992\n"); + w.write("hostType = CICS\n"); + w.write("useTls = true\n"); + } + + List result = HostStorage.importFromIniFile(tmpIni); + assertEquals(2, result.size(), "Import should result in 2 saved hosts (1 updated, 1 added)"); + + SavedHost updated = HostStorage.findById("id-100"); + assertNotNull(updated); + assertEquals("new.host.org", updated.getHost()); + assertEquals(2323, updated.getPort()); + + SavedHost added = HostStorage.findByName("Development CICS"); + assertNotNull(added); + assertEquals("cics.dev.org", added.getHost()); + assertEquals(992, added.getPort()); + assertEquals("CICS", added.getHostType()); + assertTrue(added.isUseTls()); + } finally { + if (tmpIni.exists()) tmpIni.delete(); + } + } + + @Test + @DisplayName("HostStorage parses INI section prefixes and aliases properly") + public void testImportHandlesMissingFieldsAndAliases() throws IOException { + File tmpIni = File.createTempFile("connections_alias", ".ini"); + tmpIni.deleteOnExit(); + + try { + try (FileWriter w = new FileWriter(tmpIni)) { + w.write("[connection:Aliased Host]\n"); + w.write("hostname = test.alias.net\n"); + w.write("port = 1023\n"); + w.write("terminalModel = 2\n"); + w.write("type = CMS\n"); + w.write("tls = true\n"); + w.write("verifyCert = false\n"); + w.write("charset = 1047\n"); + w.write("rows = 24\n"); + w.write("cols = 80\n"); + w.write("lu = LUVM\n"); + } + + List parsed = HostStorage.loadFromIniFile(tmpIni); + assertEquals(1, parsed.size()); + + SavedHost h = parsed.get(0); + assertEquals("Aliased Host", h.getName()); + assertEquals("test.alias.net", h.getHost()); + assertEquals(1023, h.getPort()); + assertEquals(2, h.getModel()); + assertEquals("CMS", h.getHostType()); + assertTrue(h.isUseTls()); + assertFalse(h.isTlsVerifyCert()); + assertEquals("1047", h.getCodePage()); + assertEquals(24, h.getDynamicRows()); + assertEquals(80, h.getDynamicCols()); + assertEquals("LUVM", h.getLuName()); + } finally { + if (tmpIni.exists()) tmpIni.delete(); + } + } + + @Test + @DisplayName("SettingsDialog contains Import Connections and Export Connections buttons and is taller") + public void testSettingsDialogConnectionButtonsAndHeight() { + J3270App app; + try { + app = new J3270App(); + } catch (HeadlessException e) { + return; + } + + try { + SettingsDialog dialog = new SettingsDialog(app); + + JButton btnImportConn = dialog.getBtnImportConnections(); + JButton btnExportConn = dialog.getBtnExportConnections(); + + assertNotNull(btnImportConn, "Import Connections button must exist in SettingsDialog"); + assertNotNull(btnExportConn, "Export Connections button must exist in SettingsDialog"); + + assertEquals("Import Connections...", btnImportConn.getText()); + assertEquals("Export Connections...", btnExportConn.getText()); + + // Check window height: must be taller than previous 540 to accommodate extra buttons + assertTrue(dialog.getHeight() > 540, + "Settings window height (" + dialog.getHeight() + ") must be taller than 540 to accommodate the extra buttons"); + + // Verify config buttons share container row1, and connection buttons share container row2 + JButton btnImport = dialog.getBtnImport(); + JButton btnExport = dialog.getBtnExport(); + assertNotNull(btnImport); + assertNotNull(btnExport); + + Container configRow = btnImport.getParent(); + assertNotNull(configRow); + assertSame(configRow, btnExport.getParent(), "btnImport and btnExport must share the same parent row"); + + Container connRow = btnImportConn.getParent(); + assertNotNull(connRow); + assertSame(connRow, btnExportConn.getParent(), "btnImportConn and btnExportConn must share the same parent row"); + + assertNotSame(configRow, connRow, "Connection buttons must be below and NOT in line with config buttons"); + + // Verify connRow is positioned below configRow in buttonPanel + Container buttonPanel = configRow.getParent(); + assertNotNull(buttonPanel); + assertSame(buttonPanel, connRow.getParent(), "Both rows must be in buttonPanel"); + + int configRowIdx = -1; + int connRowIdx = -1; + Component[] rows = buttonPanel.getComponents(); + for (int i = 0; i < rows.length; i++) { + if (rows[i] == configRow) configRowIdx = i; + if (rows[i] == connRow) connRowIdx = i; + } + assertTrue(configRowIdx >= 0, "configRow must be in buttonPanel"); + assertTrue(connRowIdx >= 0, "connRow must be in buttonPanel"); + assertTrue(connRowIdx > configRowIdx, "connRow must be below configRow in buttonPanel (" + connRowIdx + " > " + configRowIdx + ")"); + + dialog.dispose(); + app.dispose(); + } catch (HeadlessException ignored) { + } + } + + @Test + @DisplayName("SettingsDialog importConnectionsFile updates savedHostBox and Settings") + public void testSettingsDialogImportConnectionsFileUpdatesDropdownAndSettings() throws IOException { + J3270App app; + try { + app = new J3270App(); + } catch (HeadlessException e) { + return; + } + + File tmpIni = File.createTempFile("settings_dialog_import_test", ".ini"); + tmpIni.deleteOnExit(); + + try { + try (FileWriter w = new FileWriter(tmpIni)) { + w.write("[Auto Mainframe]\n"); + w.write("host = auto.mainframe.org\n"); + w.write("port = 992\n"); + w.write("autoConnect = true\n"); + w.write("useTls = true\n"); + } + + SettingsDialog dialog = new SettingsDialog(app); + dialog.importConnectionsFile(tmpIni); + + // Check savedHostBox updated + JComboBox box = dialog.getSavedHostBox(); + assertNotNull(box); + assertTrue(box.getItemCount() >= 1); + + SavedHost sel = (SavedHost) box.getSelectedItem(); + assertNotNull(sel); + assertEquals("auto.mainframe.org", sel.getHost()); + assertTrue(sel.isAutoConnect()); + + // Check Settings updated for autoConnect host + assertEquals("auto.mainframe.org", Settings.getAutoConnectHost()); + assertEquals(992, Settings.getAutoConnectPort()); + assertTrue(Settings.getAutoConnectTls()); + + // Test exportConnectionsFile + File exportTarget = File.createTempFile("settings_dialog_export_test", ".ini"); + exportTarget.deleteOnExit(); + dialog.exportConnectionsFile(exportTarget); + assertTrue(exportTarget.exists()); + String exported = new String(Files.readAllBytes(exportTarget.toPath())); + assertTrue(exported.contains("auto.mainframe.org")); + + dialog.dispose(); + app.dispose(); + } catch (HeadlessException ignored) { + } finally { + if (tmpIni.exists()) tmpIni.delete(); + } + } + + @Test + @DisplayName("SettingsDialog importConnectionsFile throws FileNotFoundException for nonexistent file") + public void testImportNonExistentConnectionsFileThrows() { + J3270App app; + try { + app = new J3270App(); + } catch (HeadlessException e) { + return; + } + + try { + SettingsDialog dialog = new SettingsDialog(app); + File nonExistent = new File("/path/to/nonexistent/connections_" + System.currentTimeMillis() + ".ini"); + assertThrows(FileNotFoundException.class, () -> { + dialog.importConnectionsFile(nonExistent); + }); + + dialog.dispose(); + app.dispose(); + } catch (HeadlessException ignored) { + } + } +}