diff --git a/j3270/src/test/java/haus/nightmare/j3270/Phase27BuildAndRenderTest.java b/j3270/src/test/java/haus/nightmare/j3270/Phase27BuildAndRenderTest.java new file mode 100644 index 0000000..4b66d9a --- /dev/null +++ b/j3270/src/test/java/haus/nightmare/j3270/Phase27BuildAndRenderTest.java @@ -0,0 +1,195 @@ +package haus.nightmare.j3270; + +import haus.nightmare.j3270.ui.TerminalPanel; +import haus.nightmare.lib3270j.ConnectionConfig; +import haus.nightmare.lib3270j.ConnectionState; +import haus.nightmare.lib3270j.Telnet3270Client; +import haus.nightmare.lib3270j.charset.EbcdicTranslator; +import haus.nightmare.lib3270j.nvt.NvtProcessor; +import haus.nightmare.lib3270j.screen.ScreenBuffer; +import haus.nightmare.lib3270j.telnet.TelnetFSM; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.File; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.nio.file.Files; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Verification test suite for Phase 27: + * Build Hygiene, Release Versioning & Zero-Risk Pruning (v1.2.4) + */ +public class Phase27BuildAndRenderTest { + + @Test + @DisplayName("Phase 27.1 & 27.2: Release version 1.2.4 synchronized across build files and android.useAndroidX removed") + public void testVersionSynchronizationAndGradleProperties() throws Exception { + File projectRoot = new File(".").getCanonicalFile(); + if (!new File(projectRoot, "settings.gradle").exists() && projectRoot.getParentFile() != null) { + projectRoot = projectRoot.getParentFile(); + } + + // 1. Verify build.gradle version = '1.2.4' + File buildGradle = new File(projectRoot, "build.gradle"); + assertTrue(buildGradle.exists(), "build.gradle must exist"); + String buildGradleContent = Files.readString(buildGradle.toPath()); + assertTrue(buildGradleContent.contains("version = '1.2.4'"), "build.gradle must define version = '1.2.4'"); + assertFalse(buildGradleContent.contains("0.1.0"), "build.gradle must not contain 0.1.0"); + + // 2. Verify build_all.sh Implementation-Version: 1.2.4 + File buildAll = new File(projectRoot, "build_all.sh"); + assertTrue(buildAll.exists(), "build_all.sh must exist"); + String buildAllContent = Files.readString(buildAll.toPath()); + assertTrue(buildAllContent.contains("Implementation-Version: 1.2.4"), "build_all.sh must specify Implementation-Version: 1.2.4"); + assertFalse(buildAllContent.contains("Implementation-Version: 0.1.0"), "build_all.sh must not contain 0.1.0"); + + // 3. Verify setup.ps1 Implementation-Version: 1.2.4 + File setupPs1 = new File(projectRoot, "setup.ps1"); + assertTrue(setupPs1.exists(), "setup.ps1 must exist"); + String setupPs1Content = Files.readString(setupPs1.toPath()); + assertTrue(setupPs1Content.contains("Implementation-Version: 1.2.4"), "setup.ps1 must specify Implementation-Version: 1.2.4"); + assertFalse(setupPs1Content.contains("Implementation-Version: 0.1.0"), "setup.ps1 must not contain 0.1.0"); + + // 4. Verify setup.sh Implementation-Version: 1.2.4 + File setupSh = new File(projectRoot, "setup.sh"); + assertTrue(setupSh.exists(), "setup.sh must exist"); + String setupShContent = Files.readString(setupSh.toPath()); + assertTrue(setupShContent.contains("Implementation-Version: 1.2.4"), "setup.sh must specify Implementation-Version: 1.2.4"); + assertFalse(setupShContent.contains("Implementation-Version: 0.1.0"), "setup.sh must not contain 0.1.0"); + + // 5. Verify gradle.properties android.useAndroidX removed + File gradleProps = new File(projectRoot, "gradle.properties"); + assertTrue(gradleProps.exists(), "gradle.properties must exist"); + String gradlePropsContent = Files.readString(gradleProps.toPath()); + assertFalse(gradlePropsContent.contains("android.useAndroidX"), "gradle.properties must not contain android.useAndroidX"); + } + + @Test + @DisplayName("Phase 27.3: T1-B Static Color & BasicStroke constants promoted in TerminalPanel") + public void testHotspotHoverStaticConstants() throws Exception { + Field colorField = TerminalPanel.class.getDeclaredField("HOTSPOT_HOVER_COLOR"); + colorField.setAccessible(true); + assertTrue(Modifier.isStatic(colorField.getModifiers()), "HOTSPOT_HOVER_COLOR must be static"); + assertTrue(Modifier.isFinal(colorField.getModifiers()), "HOTSPOT_HOVER_COLOR must be final"); + Color hoverColor = (Color) colorField.get(null); + assertEquals(new Color(0, 220, 255), hoverColor, "HOTSPOT_HOVER_COLOR must match bright cyan (0, 220, 255)"); + + Field strokeField = TerminalPanel.class.getDeclaredField("HOTSPOT_HOVER_STROKE"); + strokeField.setAccessible(true); + assertTrue(Modifier.isStatic(strokeField.getModifiers()), "HOTSPOT_HOVER_STROKE must be static"); + assertTrue(Modifier.isFinal(strokeField.getModifiers()), "HOTSPOT_HOVER_STROKE must be final"); + Stroke hoverStroke = (Stroke) strokeField.get(null); + assertNotNull(hoverStroke, "HOTSPOT_HOVER_STROKE must not be null"); + assertTrue(hoverStroke instanceof BasicStroke, "HOTSPOT_HOVER_STROKE must be BasicStroke"); + assertEquals(2.0f, ((BasicStroke) hoverStroke).getLineWidth(), 0.001f, "HOTSPOT_HOVER_STROKE line width must be 2.0f"); + } + + @Test + @DisplayName("Phase 27.4: T1-A charScratch Buffer exists and non-ASCII render renders without exceptions") + public void testCharScratchAndNonAsciiRendering() throws Exception { + Field scratchField = TerminalPanel.class.getDeclaredField("charScratch"); + scratchField.setAccessible(true); + assertFalse(Modifier.isStatic(scratchField.getModifiers()), "charScratch should be an instance field confined to EDT"); + assertTrue(Modifier.isFinal(scratchField.getModifiers()), "charScratch should be final"); + + TerminalPanel panel = new TerminalPanel(); + panel.setSize(800, 600); + + char[] scratch = (char[]) scratchField.get(panel); + assertNotNull(scratch, "charScratch must not be null"); + assertEquals(1, scratch.length, "charScratch must be length 1"); + + // Set up client with non-ASCII characters (e.g. U+2500 box drawing, U+00E9 acute e, U+2550 double box) + Telnet3270Client client = new Telnet3270Client(new ConnectionConfig("localhost", 23)); + client.getTelnetFSM().setConnectionState(ConnectionState.CONNECTED_3270); + ScreenBuffer sb = client.getScreenBuffer(); + sb.erase(false); + + // Put non-ASCII characters on screen + sb.getCell(0).ucs4 = 0x2500; // Box drawing horizontal line + sb.getCell(1).ucs4 = 0x00E9; // e with acute + sb.getCell(2).ucs4 = 0x2550; // Box drawing double horizontal + sb.getCell(3).ucs4 = 0x2308; // APL left ceiling + sb.updateDisplaySnapshot(); + + panel.setClient(client); + + BufferedImage img = new BufferedImage(800, 600, BufferedImage.TYPE_INT_ARGB); + Graphics2D g2 = img.createGraphics(); + assertDoesNotThrow(() -> panel.paint(g2), "paint must render non-ASCII cells via drawChars without error"); + g2.dispose(); + + // Check that charScratch was used + assertTrue(scratch[0] > 0, "charScratch should have held the last drawn non-ASCII character"); + } + + @Test + @DisplayName("Phase 27.5: T1-C Reusable NVT Single-Byte Buffer dispatches without allocations") + public void testReusableNvtSingleByteBuffer() throws Exception { + Field scratchField = TelnetFSM.class.getDeclaredField("nvtSingleByteScratch"); + scratchField.setAccessible(true); + assertFalse(Modifier.isStatic(scratchField.getModifiers()), "nvtSingleByteScratch must be an instance field"); + assertTrue(Modifier.isFinal(scratchField.getModifiers()), "nvtSingleByteScratch must be final"); + + Telnet3270Client client = new Telnet3270Client(new ConnectionConfig("localhost", 23)); + TelnetFSM fsm = client.getTelnetFSM(); + byte[] expectedScratch = (byte[]) scratchField.get(fsm); + assertNotNull(expectedScratch, "nvtSingleByteScratch must not be null"); + assertEquals(1, expectedScratch.length, "nvtSingleByteScratch must have length 1"); + + fsm.setConnectionState(ConnectionState.CONNECTED_NVT); + + AtomicBoolean dispatched = new AtomicBoolean(false); + AtomicReference receivedBuffer = new AtomicReference<>(); + + NvtProcessor customNvtProcessor = new NvtProcessor(client.getScreenBuffer(), new EbcdicTranslator()) { + @Override + public void processNVTData(byte[] buffer, int offset, int length) { + dispatched.set(true); + receivedBuffer.set(buffer); + assertEquals(0, offset); + assertEquals(1, length); + assertEquals((byte) 'Z', buffer[offset]); + super.processNVTData(buffer, offset, length); + } + }; + + Field nvtProcField = TelnetFSM.class.getDeclaredField("nvtProcessor"); + nvtProcField.setAccessible(true); + nvtProcField.set(fsm, customNvtProcessor); + + // Feed single byte 'Z' into FSM + fsm.feedByte('Z'); + + assertTrue(dispatched.get(), "NvtProcessor.processNVTData should have been called"); + assertSame(expectedScratch, receivedBuffer.get(), "Buffer passed to processNVTData must be the reusable nvtSingleByteScratch array instance"); + } + + @Test + @DisplayName("Phase 27.6: J3270App has Settings import and no in-line FQCN occurrences") + public void testJ3270AppSettingsImport() throws Exception { + File projectRoot = new File(".").getCanonicalFile(); + if (!new File(projectRoot, "j3270").exists() && projectRoot.getParentFile() != null) { + projectRoot = projectRoot.getParentFile(); + } + File appFile = new File(projectRoot, "j3270/src/main/java/haus/nightmare/j3270/J3270App.java"); + assertTrue(appFile.exists(), "J3270App.java must exist"); + String content = Files.readString(appFile.toPath()); + + assertTrue(content.contains("import haus.nightmare.j3270.config.Settings;"), + "J3270App.java must import haus.nightmare.j3270.config.Settings"); + + // The only occurrence of haus.nightmare.j3270.config.Settings should be in the import + int firstIndex = content.indexOf("haus.nightmare.j3270.config.Settings"); + assertTrue(firstIndex >= 0, "Must contain import"); + int secondIndex = content.indexOf("haus.nightmare.j3270.config.Settings", firstIndex + 1); + assertEquals(-1, secondIndex, "There should be no inline occurrences of haus.nightmare.j3270.config.Settings in J3270App"); + } +} diff --git a/j3270/src/test/java/haus/nightmare/j3270/Phase28UiOptimizationTest.java b/j3270/src/test/java/haus/nightmare/j3270/Phase28UiOptimizationTest.java new file mode 100644 index 0000000..20008cd --- /dev/null +++ b/j3270/src/test/java/haus/nightmare/j3270/Phase28UiOptimizationTest.java @@ -0,0 +1,341 @@ +package haus.nightmare.j3270; + +import haus.nightmare.j3270.ui.FieldInspectorDialog; +import haus.nightmare.j3270.ui.FindDialog; +import haus.nightmare.j3270.ui.HotspotEngine; +import haus.nightmare.j3270.ui.ScriptDialog; +import haus.nightmare.j3270.ui.TerminalPanel; +import haus.nightmare.lib3270j.ConnectionConfig; +import haus.nightmare.lib3270j.ConnectionState; +import haus.nightmare.lib3270j.Telnet3270Client; +import haus.nightmare.lib3270j.screen.ScreenBuffer; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.awt.event.MouseEvent; +import java.awt.event.MouseMotionListener; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Verification test suite for Phase 28: + * UI Responsiveness, Dialog Lifecycle & Event Optimization + */ +public class Phase28UiOptimizationTest { + + private void setField(Object obj, String fieldName, Object val) throws Exception { + Field f = obj.getClass().getDeclaredField(fieldName); + f.setAccessible(true); + f.set(obj, val); + } + + private void writeString(ScreenBuffer sb, int row, int col, String text) { + int cols = sb.getDisplayCols(); + int base = row * cols + col; + for (int i = 0; i < text.length(); i++) { + sb.getCell(base + i).ucs4 = text.charAt(i); + } + sb.updateDisplaySnapshot(); + } + + @Test + @DisplayName("Phase 28.1: T2-A Hotspot Dirty Flagging & Lazy Text Scraping eliminates redundant scans on mouseMoved") + public void testRapidMouseMoveEventsScanOnlyOnce() throws Exception { + TerminalPanel panel = new TerminalPanel(); + panel.setSize(800, 600); + + Telnet3270Client client = new Telnet3270Client(new ConnectionConfig("localhost", 23)); + client.getTelnetFSM().setConnectionState(ConnectionState.CONNECTED_3270); + ScreenBuffer sb = client.getScreenBuffer(); + sb.erase(false); + + // Put hotspots on screen: PF keys and URL + writeString(sb, 0, 0, "APPLICATION SELECTION MENU"); + writeString(sb, 2, 0, "PF1=Help PF3=Exit PF12=Cancel"); + writeString(sb, 4, 0, "Visit http://example.com/3270 for assistance"); + sb.updateDisplaySnapshot(); + + panel.setClient(client); + HotspotEngine engine = panel.getHotspotEngine(); + engine.setUrlEnabled(true); + engine.setPfKeyEnabled(true); + engine.setMenuChoiceEnabled(true); + + assertTrue(panel.isHotspotsDirty(), "Hotspots should initially be dirty on panel setup"); + long initialScanCount = engine.getScanCount(); + + MouseMotionListener[] motionListeners = panel.getMouseMotionListeners(); + assertTrue(motionListeners.length > 0, "TerminalPanel must have at least one MouseMotionListener"); + MouseMotionListener mml = motionListeners[0]; + + // Simulate 1,000 rapid mouse move events across the terminal grid + for (int i = 0; i < 1000; i++) { + int x = 20 + (i % 200); + int y = 30 + (i % 150); + MouseEvent me = new MouseEvent(panel, MouseEvent.MOUSE_MOVED, System.currentTimeMillis(), 0, x, y, 0, false); + mml.mouseMoved(me); + } + + // After 1,000 moves, hotspotEngine.scan() should only have executed ONCE + assertEquals(initialScanCount + 1, engine.getScanCount(), + "hotspotEngine.scan() should execute exactly once across 1,000 rapid mouse move events on a clean screen"); + assertFalse(panel.isHotspotsDirty(), "Hotspots must be marked clean after scanning"); + + // Verify hotspots were actually discovered + List hotspots = engine.getHotspots(); + assertFalse(hotspots.isEmpty(), "Hotspots on screen must be found"); + assertNotNull(engine.findHotspot(2, 0), "Hotspot at row 2 col 0 (PF1) must be found"); + + // Direct calls to HotspotEngine.scan(sb) without snapshot version changes must also bypass StringBuilder + long beforeDirectCalls = engine.getScanCount(); + for (int i = 0; i < 1000; i++) { + List res = engine.scan(sb); + assertFalse(res.isEmpty()); + } + assertEquals(beforeDirectCalls, engine.getScanCount(), + "Direct calls to HotspotEngine.scan(sb) without snapshot version change must return cachedHotspots"); + + // Now simulate screen update + writeString(sb, 6, 0, "NEW UPDATE: PF7=Up PF8=Down"); + panel.onScreenUpdated(); + assertTrue(panel.isHotspotsDirty(), "onScreenUpdated() must flag hotspotsDirty = true"); + + // Simulate another burst of 1,000 rapid mouse moves + for (int i = 0; i < 1000; i++) { + int x = 30 + (i % 200); + int y = 40 + (i % 150); + MouseEvent me = new MouseEvent(panel, MouseEvent.MOUSE_MOVED, System.currentTimeMillis(), 0, x, y, 0, false); + mml.mouseMoved(me); + } + + assertEquals(initialScanCount + 2, engine.getScanCount(), + "After screen update, exactly one more scan should run across 1,000 mouse moves"); + assertFalse(panel.isHotspotsDirty(), "Hotspots must be marked clean again after rescan"); + } + + @Test + @DisplayName("Phase 28.2: T2-B Precomputed Selection Scratch Lookup matches isCellSelected exactly across all modes") + public void testSelectionBoundsMatchingPrecomputedScratchVsLegacy() throws Exception { + TerminalPanel panel = new TerminalPanel(); + panel.setSize(800, 600); + + Telnet3270Client client = new Telnet3270Client(new ConnectionConfig("localhost", 23)); + client.getTelnetFSM().setConnectionState(ConnectionState.CONNECTED_3270); + panel.setClient(client); + + int rows = 24; + int cols = 80; + + // Configuration 1: No selection + panel.clearSelection(); + panel.precomputeSelectionScratch(rows, cols); + boolean[] scratch = panel.getSelectedCellsScratch(); + assertEquals(rows * cols, scratch.length); + for (int r = 0; r < rows; r++) { + for (int c = 0; c < cols; c++) { + int baddr = r * cols + c; + assertFalse(scratch[baddr], "No cell should be selected"); + assertEquals(panel.isCellSelected(r, c), scratch[baddr]); + } + } + + // Configuration 2: Linear / Stream mode forward selection (spanning multiple rows) + setField(panel, "blockSelectMode", false); + setField(panel, "trimRectSizingHandles", false); + setField(panel, "selectionStartRow", 2); + setField(panel, "selectionStartCol", 15); + setField(panel, "selectionEndRow", 5); + setField(panel, "selectionEndCol", 42); + + panel.precomputeSelectionScratch(rows, cols); + scratch = panel.getSelectedCellsScratch(); + for (int r = 0; r < rows; r++) { + for (int c = 0; c < cols; c++) { + int baddr = r * cols + c; + boolean expected = panel.isCellSelected(r, c); + assertEquals(expected, scratch[baddr], "Mismatch in linear forward mode at row " + r + ", col " + c); + } + } + + // Configuration 3: Linear / Stream mode backward selection (drag upwards) + setField(panel, "selectionStartRow", 8); + setField(panel, "selectionStartCol", 30); + setField(panel, "selectionEndRow", 4); + setField(panel, "selectionEndCol", 10); + + panel.precomputeSelectionScratch(rows, cols); + scratch = panel.getSelectedCellsScratch(); + for (int r = 0; r < rows; r++) { + for (int c = 0; c < cols; c++) { + int baddr = r * cols + c; + boolean expected = panel.isCellSelected(r, c); + assertEquals(expected, scratch[baddr], "Mismatch in linear backward mode at row " + r + ", col " + c); + } + } + + // Configuration 4: Block select mode + setField(panel, "blockSelectMode", true); + setField(panel, "trimRectSizingHandles", false); + setField(panel, "selectionStartRow", 3); + setField(panel, "selectionStartCol", 20); + setField(panel, "selectionEndRow", 7); + setField(panel, "selectionEndCol", 60); + + panel.precomputeSelectionScratch(rows, cols); + scratch = panel.getSelectedCellsScratch(); + for (int r = 0; r < rows; r++) { + for (int c = 0; c < cols; c++) { + int baddr = r * cols + c; + boolean expected = panel.isCellSelected(r, c); + assertEquals(expected, scratch[baddr], "Mismatch in block select mode at row " + r + ", col " + c); + } + } + + // Configuration 5: Trim Rect Sizing Handles mode (block geometry) + setField(panel, "blockSelectMode", false); + setField(panel, "trimRectSizingHandles", true); + setField(panel, "selectionStartRow", 10); + setField(panel, "selectionStartCol", 5); + setField(panel, "selectionEndRow", 15); + setField(panel, "selectionEndCol", 25); + + panel.precomputeSelectionScratch(rows, cols); + scratch = panel.getSelectedCellsScratch(); + for (int r = 0; r < rows; r++) { + for (int c = 0; c < cols; c++) { + int baddr = r * cols + c; + boolean expected = panel.isCellSelected(r, c); + assertEquals(expected, scratch[baddr], "Mismatch in trim handles mode at row " + r + ", col " + c); + } + } + + // Configuration 6: Model 5 Dynamic Geometry (27 rows x 132 cols) + rows = 27; + cols = 132; + client.getScreenBuffer().setDimensions(rows, cols); + client.getScreenBuffer().updateDisplaySnapshot(); + setField(panel, "selectionStartRow", 1); + setField(panel, "selectionStartCol", 1); + setField(panel, "selectionEndRow", 25); + setField(panel, "selectionEndCol", 130); + + panel.precomputeSelectionScratch(rows, cols); + scratch = panel.getSelectedCellsScratch(); + assertEquals(rows * cols, scratch.length); + for (int r = 0; r < rows; r++) { + for (int c = 0; c < cols; c++) { + int baddr = r * cols + c; + boolean expected = panel.isCellSelected(r, c); + assertEquals(expected, scratch[baddr], "Mismatch in Model 5 27x132 at row " + r + ", col " + c); + } + } + } + + @Test + @DisplayName("Phase 28.3: Dialog Lifecycle & Reconnection Hardening updates cached dialogs with active client") + public void testReconnectCycleFindDialogInteractsWithNewClient() throws Exception { + // 1. Verify API contract and reflection for dialog lifecycle methods + Method findSetClient = FindDialog.class.getMethod("setClient", Telnet3270Client.class); + assertNotNull(findSetClient); + Method findGetClient = FindDialog.class.getMethod("getClient"); + assertEquals(Telnet3270Client.class, findGetClient.getReturnType()); + + Method scriptSetClient = ScriptDialog.class.getMethod("setClient", Telnet3270Client.class); + assertNotNull(scriptSetClient); + Method scriptGetClient = ScriptDialog.class.getMethod("getClient"); + assertEquals(Telnet3270Client.class, scriptGetClient.getReturnType()); + + Method fieldSetClient = FieldInspectorDialog.class.getMethod("setClient", Telnet3270Client.class); + assertNotNull(fieldSetClient); + Method fieldGetClient = FieldInspectorDialog.class.getMethod("getClient"); + assertEquals(Telnet3270Client.class, fieldGetClient.getReturnType()); + + Method appGetFind = J3270App.class.getMethod("getFindDialog"); + assertEquals(FindDialog.class, appGetFind.getReturnType()); + Method appGetScript = J3270App.class.getMethod("getScriptDialog"); + assertEquals(ScriptDialog.class, appGetScript.getReturnType()); + Method appGetField = J3270App.class.getMethod("getFieldInspectorDialog"); + assertEquals(FieldInspectorDialog.class, appGetField.getReturnType()); + + // 2. If non-headless environment, perform end-to-end interactive GUI verification + try { + J3270App app = new J3270App(); + TerminalPanel panel = app.getTerminalPanel(); + + // Session 1: Client 1 + ConnectionConfig config1 = new ConnectionConfig("host1.example.com", 23); + Telnet3270Client client1 = new Telnet3270Client(config1); + client1.getTelnetFSM().setConnectionState(ConnectionState.CONNECTED_3270); + writeString(client1.getScreenBuffer(), 1, 0, "SYSTEM_SESSION_ALPHA_123"); + + panel.setClient(client1); + setField(app, "client", client1); + + // Open FindDialog on session 1 + FindDialog findDialog = new FindDialog(app, client1, panel); + assertSame(client1, findDialog.getClient()); + + // Search for string on Client 1 + findDialog.setSearchText("SYSTEM_SESSION_ALPHA_123"); + findDialog.findNext(); + int expectedPos1 = 1 * client1.getScreenBuffer().getDisplayCols(); + assertEquals(expectedPos1, client1.getScreenBuffer().getCursorAddress(), + "FindDialog must move cursor to match on client1"); + + // Simulate Session Disconnect + setField(app, "findDialog", findDialog); + app.disconnect(false); + assertNull(findDialog.getClient(), "Disconnect must nullify client in cached dialog"); + + // Subsequent find attempt while disconnected should report disconnected without errors + findDialog.findNext(); + assertEquals(expectedPos1, client1.getScreenBuffer().getCursorAddress()); + + // Session 2: Reconnection to Client 2 + ConnectionConfig config2 = new ConnectionConfig("host2.example.com", 23); + Telnet3270Client client2 = new Telnet3270Client(config2); + client2.getTelnetFSM().setConnectionState(ConnectionState.CONNECTED_3270); + writeString(client2.getScreenBuffer(), 3, 10, "MAINFRAME_SESSION_BETA_999"); + + // Simulate reconnect hook in J3270App + findDialog.setClient(client2); + assertSame(client2, findDialog.getClient(), "FindDialog must now hold client2"); + + // Search for string on Client 2 + findDialog.setSearchText("MAINFRAME_SESSION_BETA_999"); + findDialog.findNext(); + int expectedPos2 = 3 * client2.getScreenBuffer().getDisplayCols() + 10; + assertEquals(expectedPos2, client2.getScreenBuffer().getCursorAddress(), + "FindDialog must move cursor to match on active client2 after reconnect"); + + // Verify search for old client1 string fails on client2 + int oldCursorPos = client2.getScreenBuffer().getCursorAddress(); + findDialog.setSearchText("SYSTEM_SESSION_ALPHA_123"); + findDialog.findNext(); + assertEquals(oldCursorPos, client2.getScreenBuffer().getCursorAddress(), + "Search for stale text should not match on client2"); + + // Verify ScriptDialog and FieldInspectorDialog client lifecycle + ScriptDialog scriptDialog = new ScriptDialog(app, client1, panel); + assertSame(client1, scriptDialog.getClient()); + scriptDialog.setClient(client2); + assertSame(client2, scriptDialog.getClient()); + + FieldInspectorDialog fieldInspector = new FieldInspectorDialog(app, client1, panel); + assertSame(client1, fieldInspector.getClient()); + fieldInspector.setClient(client2); + assertSame(client2, fieldInspector.getClient()); + + // Cleanup + fieldInspector.dispose(); + findDialog.dispose(); + scriptDialog.dispose(); + app.closeWindow(); + } catch (java.awt.HeadlessException ignored) { + // Running in headless mode: GUI instantiation safely bypassed + } + } +} diff --git a/lib3270j/src/test/java/haus/nightmare/lib3270j/Phase30LibraryDistributionTest.java b/lib3270j/src/test/java/haus/nightmare/lib3270j/Phase30LibraryDistributionTest.java new file mode 100644 index 0000000..fe75ce5 --- /dev/null +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/Phase30LibraryDistributionTest.java @@ -0,0 +1,143 @@ +package haus.nightmare.lib3270j; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.nio.file.Files; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Verification test suite for Phase 30: + * Library Distribution, Maven Artifacts & Release Tagging (v1.2.4) + */ +public class Phase30LibraryDistributionTest { + + private File locateProjectRoot() { + File current = new File(".").getAbsoluteFile(); + while (current != null) { + if (new File(current, "settings.gradle").exists() || new File(current, "build.gradle").exists()) { + if (new File(current, "lib3270j").exists() && new File(current, "j3270").exists()) { + return current; + } + } + current = current.getParentFile(); + } + return new File(".").getAbsoluteFile(); + } + + @Test + @DisplayName("Phase 30.1: lib3270j/build.gradle configures maven-publish, POM metadata, and artifacts") + public void testMavenPublishConfigurationInBuildGradle() throws Exception { + File projectRoot = locateProjectRoot(); + File libGradleFile = new File(projectRoot, "lib3270j/build.gradle"); + assertTrue(libGradleFile.exists(), "lib3270j/build.gradle must exist"); + + String content = Files.readString(libGradleFile.toPath()); + + // Plugin validation + assertTrue(content.contains("id 'maven-publish'"), "maven-publish plugin must be applied"); + assertTrue(content.contains("withSourcesJar()"), "withSourcesJar() must be enabled"); + assertTrue(content.contains("withJavadocJar()"), "withJavadocJar() must be enabled"); + + // Publication coordinates + assertTrue(content.contains("groupId = 'haus.nightmare.j3270'"), "groupId must be haus.nightmare.j3270"); + assertTrue(content.contains("artifactId = 'lib3270j'"), "artifactId must be lib3270j"); + + // POM metadata + assertTrue(content.contains("name = 'lib3270j'"), "POM name must be lib3270j"); + assertTrue(content.contains("description = 'Pure Java IBM 3270 terminal emulation and protocol library with Host On-Demand (HOD) parity'"), + "POM description must describe HOD parity"); + assertTrue(content.contains("The Unlicense"), "POM must declare The Unlicense"); + assertFalse(content.contains("Apache License"), "POM must not declare Apache License"); + assertTrue(content.contains("scm:git:git://github.com/rudi/j3270.git"), "POM must declare SCM connection"); + } + + @Test + @DisplayName("Phase 30.2: Standalone build scripts package lib3270j.jar with correct manifest metadata") + public void testBuildScriptsStandaloneLibraryPackaging() throws Exception { + File projectRoot = locateProjectRoot(); + + // 1. build_all.sh + File buildAll = new File(projectRoot, "build_all.sh"); + assertTrue(buildAll.exists(), "build_all.sh must exist"); + String buildAllContent = Files.readString(buildAll.toPath()); + assertTrue(buildAllContent.contains("lib3270j.jar"), "build_all.sh must package lib3270j.jar"); + assertTrue(buildAllContent.contains("Implementation-Title: lib3270j"), "build_all.sh must set Implementation-Title: lib3270j"); + assertTrue(buildAllContent.contains("Implementation-Version: 1.2.4"), "build_all.sh must set Implementation-Version: 1.2.4"); + + // 2. setup.sh + File setupSh = new File(projectRoot, "setup.sh"); + assertTrue(setupSh.exists(), "setup.sh must exist"); + String setupShContent = Files.readString(setupSh.toPath()); + assertTrue(setupShContent.contains("lib3270j.jar"), "setup.sh must package lib3270j.jar"); + assertTrue(setupShContent.contains("Implementation-Title: lib3270j"), "setup.sh must set Implementation-Title: lib3270j"); + assertTrue(setupShContent.contains("Implementation-Version: 1.2.4"), "setup.sh must set Implementation-Version: 1.2.4"); + + // 3. setup.ps1 + File setupPs1 = new File(projectRoot, "setup.ps1"); + assertTrue(setupPs1.exists(), "setup.ps1 must exist"); + String setupPs1Content = Files.readString(setupPs1.toPath()); + assertTrue(setupPs1Content.contains("lib3270j.jar"), "setup.ps1 must package lib3270j.jar"); + assertTrue(setupPs1Content.contains("Implementation-Title: lib3270j"), "setup.ps1 must set Implementation-Title: lib3270j"); + assertTrue(setupPs1Content.contains("Implementation-Version: 1.2.4"), "setup.ps1 must set Implementation-Version: 1.2.4"); + } + + @Test + @DisplayName("Phase 30.2: Core subsystem packages and classes are present and loadable") + public void testCoreSubsystemsPresent() { + // Telnet & Protocol + assertDoesNotThrow(() -> Class.forName("haus.nightmare.lib3270j.telnet.TelnetFSM")); + assertDoesNotThrow(() -> Class.forName("haus.nightmare.lib3270j.Telnet3270Client")); + + // Screen Buffer & Concurrency + assertDoesNotThrow(() -> Class.forName("haus.nightmare.lib3270j.screen.ScreenBuffer")); + assertDoesNotThrow(() -> Class.forName("haus.nightmare.lib3270j.screen.ScreenBuffer$DisplaySnapshot")); + assertDoesNotThrow(() -> Class.forName("haus.nightmare.lib3270j.screen.ExtendedAttribute")); + + // Charset & EBCDIC + assertDoesNotThrow(() -> Class.forName("haus.nightmare.lib3270j.charset.EbcdicTranslator")); + assertDoesNotThrow(() -> Class.forName("haus.nightmare.lib3270j.charset.CodePage")); + + // GOCA & Graphics + assertDoesNotThrow(() -> Class.forName("haus.nightmare.lib3270j.graphics.GocaDecoder")); + assertDoesNotThrow(() -> Class.forName("haus.nightmare.lib3270j.graphics.GraphicsMode")); + + // ECL (standard and HOD compatibility namespaces) + assertDoesNotThrow(() -> Class.forName("haus.nightmare.lib3270j.ecl.ECLSession")); + assertDoesNotThrow(() -> Class.forName("haus.nightmare.lib3270j.eNetwork.ECL.ECLSession")); + assertDoesNotThrow(() -> Class.forName("com.ibm.eNetwork.ECL.ECLSession")); + assertDoesNotThrow(() -> Class.forName("com.ibm.eNetwork.ECL.ECLPS")); + assertDoesNotThrow(() -> Class.forName("com.ibm.eNetwork.beans.HOD.Macro")); + + // FastByteBuffer de-synchronized accumulation + assertDoesNotThrow(() -> Class.forName("haus.nightmare.lib3270j.datastream.FastByteBuffer")); + } + + @Test + @DisplayName("Phase 30.2: Maven local published artifacts exist and validate if maven-publish was executed") + public void testMavenLocalPublishedArtifacts() throws Exception { + String userHome = System.getProperty("user.home"); + File m2ArtifactDir = new File(userHome, ".m2/repository/haus/nightmare/j3270/lib3270j/1.2.4"); + if (m2ArtifactDir.exists()) { + File pomFile = new File(m2ArtifactDir, "lib3270j-1.2.4.pom"); + File jarFile = new File(m2ArtifactDir, "lib3270j-1.2.4.jar"); + File sourcesFile = new File(m2ArtifactDir, "lib3270j-1.2.4-sources.jar"); + File javadocFile = new File(m2ArtifactDir, "lib3270j-1.2.4-javadoc.jar"); + + assertTrue(pomFile.exists() && pomFile.length() > 0, "POM file must exist and be non-empty"); + assertTrue(jarFile.exists() && jarFile.length() > 0, "JAR file must exist and be non-empty"); + assertTrue(sourcesFile.exists() && sourcesFile.length() > 0, "Sources JAR must exist and be non-empty"); + assertTrue(javadocFile.exists() && javadocFile.length() > 0, "Javadoc JAR must exist and be non-empty"); + + String pomXml = Files.readString(pomFile.toPath()); + assertTrue(pomXml.contains("haus.nightmare.j3270"), "POM must contain groupId"); + assertTrue(pomXml.contains("lib3270j"), "POM must contain artifactId"); + assertTrue(pomXml.contains("1.2.4"), "POM must contain version 1.2.4"); + assertTrue(pomXml.contains("The Unlicense"), "POM must contain Unlicense"); + assertFalse(pomXml.contains("Apache"), "lib3270j POM must not contain Apache license"); + assertFalse(pomXml.contains(""), "lib3270j POM must have zero external dependencies"); + } + } +} diff --git a/lib3270j/src/test/java/haus/nightmare/lib3270j/screen/Phase29ConcurrencyStressTest.java b/lib3270j/src/test/java/haus/nightmare/lib3270j/screen/Phase29ConcurrencyStressTest.java new file mode 100644 index 0000000..b570f52 --- /dev/null +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/screen/Phase29ConcurrencyStressTest.java @@ -0,0 +1,259 @@ +package haus.nightmare.lib3270j.screen; + +import haus.nightmare.lib3270j.charset.EbcdicTranslator; +import haus.nightmare.lib3270j.datastream.FastByteBuffer; +import haus.nightmare.lib3270j.TerminalModel; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.nio.ByteBuffer; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Verification test suite for Phase 29: + * Lock-Free Concurrency Architecture & Buffer De-Synchronization + */ +public class Phase29ConcurrencyStressTest { + + @Test + @DisplayName("Phase 29.1: FastByteBuffer methods have synchronized modifier removed for thread-confined stream accumulation") + public void testFastByteBufferMethodsAreUnsynchronized() throws Exception { + Class clazz = FastByteBuffer.class; + String[] methodNames = { + "write", "buffer", "size", "reset", "asByteBuffer", "slice", "toByteArray" + }; + + for (Method method : clazz.getDeclaredMethods()) { + for (String name : methodNames) { + if (method.getName().equals(name)) { + int mod = method.getModifiers(); + assertFalse(Modifier.isSynchronized(mod), + "FastByteBuffer method '" + method.getName() + "' must NOT have synchronized modifier"); + } + } + } + + // Functional stream test + FastByteBuffer fbb = new FastByteBuffer(128); + for (int i = 0; i < 5000; i++) { + fbb.write(i & 0xFF); + } + assertEquals(5000, fbb.size()); + assertEquals((byte) 0, fbb.buffer()[0]); + assertEquals((byte) (4999 & 0xFF), fbb.buffer()[4999]); + + ByteBuffer bb = fbb.asByteBuffer(); + assertEquals(5000, bb.remaining()); + ByteBuffer sl = fbb.slice(10, 20); + assertEquals(20, sl.remaining()); + + fbb.reset(); + assertEquals(0, fbb.size()); + } + + @Test + @DisplayName("Phase 29.2: 10 worker threads reading getDisplayCell continuously while network thread writes 5,000 updates") + public void testConcurrentDisplayCellReadsDuringHighFrequencyUpdates() throws Exception { + ScreenBuffer sb = new ScreenBuffer(TerminalModel.IBM_3279_2, new EbcdicTranslator()); + int size = sb.getRows() * sb.getCols(); + + int workerThreadCount = 10; + int totalUpdates = 5000; + ExecutorService executor = Executors.newFixedThreadPool(workerThreadCount + 1); + AtomicBoolean running = new AtomicBoolean(true); + AtomicInteger tornCellCount = new AtomicInteger(0); + AtomicInteger exceptionCount = new AtomicInteger(0); + AtomicLong totalReads = new AtomicLong(0); + + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch writersDoneLatch = new CountDownLatch(1); + + // Spin 10 worker threads reading getDisplayCell() continuously + for (int w = 0; w < workerThreadCount; w++) { + executor.submit(() -> { + try { + startLatch.await(); + while (running.get()) { + int rows = sb.getDisplayRows(); + int cols = sb.getDisplayCols(); + int curAddr = sb.getDisplayCursorAddress(); + long version = sb.getSnapshotVersion(); + + assertTrue(rows > 0, "Display rows must be positive"); + assertTrue(cols > 0, "Display cols must be positive"); + assertTrue(curAddr >= 0, "Display cursor must be non-negative"); + + int cellCount = rows * cols; + for (int addr = 0; addr < cellCount; addr++) { + ExtendedAttribute cell = sb.getDisplayCell(addr); + if (cell == null) { + exceptionCount.incrementAndGet(); + continue; + } + totalReads.incrementAndGet(); + + // Validate torn cell state: + // In our patterned writer, every cell written in iteration N has: + // ucs4 = (char) ('A' + (N % 26)) + // ec = (byte) (0x10 + (N % 26)) + // fg = (byte) (0x30 + (N % 26)) + // bg = (byte) (0x50 + (N % 26)) + // A torn read occurs if ucs4, ec, fg, or bg don't match the same iteration! + if (cell.ucs4 >= 'A' && cell.ucs4 <= 'Z') { + int ucsOffset = cell.ucs4 - 'A'; + int ecOffset = (cell.ec & 0xFF) - 0x10; + int fgOffset = (cell.fg & 0xFF) - 0x30; + int bgOffset = (cell.bg & 0xFF) - 0x50; + + if (ucsOffset != ecOffset || ucsOffset != fgOffset || ucsOffset != bgOffset) { + tornCellCount.incrementAndGet(); + } + } + } + } + } catch (Throwable t) { + exceptionCount.incrementAndGet(); + } + }); + } + + // Writer task simulating network thread writing 5,000 screen updates + Future writerFuture = executor.submit(() -> { + try { + startLatch.await(); + for (int iter = 0; iter < totalUpdates; iter++) { + int mod = iter % 26; + char ch = (char) ('A' + mod); + byte ec = (byte) (0x10 + mod); + byte fg = (byte) (0x30 + mod); + byte bg = (byte) (0x50 + mod); + + // Mutate screen buffer + ExtendedAttribute[] rawBuf = sb.getBuffer(); + for (int i = 0; i < size; i++) { + ExtendedAttribute cell = rawBuf[i]; + cell.ucs4 = ch; + cell.ec = ec; + cell.fg = fg; + cell.bg = bg; + } + sb.setCursorAddress(iter % size); + sb.updateDisplaySnapshot(); + } + } catch (Throwable t) { + exceptionCount.incrementAndGet(); + } finally { + writersDoneLatch.countDown(); + } + }); + + // Trigger start + startLatch.countDown(); + + // Await completion of 5,000 updates with timeout to verify 0 deadlocks + boolean writerFinished = writersDoneLatch.await(15, TimeUnit.SECONDS); + assertTrue(writerFinished, "Writer must complete 5,000 updates without deadlock"); + + // Stop workers + running.set(false); + executor.shutdown(); + assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS), "Worker threads must terminate cleanly"); + + // Assert zero errors, zero torn reads, and high-volume lock-free reads verified + assertEquals(0, exceptionCount.get(), "Zero exceptions (NPE/AIOOBE) must occur during concurrent execution"); + assertEquals(0, tornCellCount.get(), "Zero torn cell states must occur during atomic DisplaySnapshot swapping"); + assertTrue(totalReads.get() > 100_000, "Worker threads must execute high-throughput lock-free reads (actual: " + totalReads.get() + ")"); + } + + @Test + @DisplayName("Phase 29.2: Screen dimension resizing under concurrent reader load produces 0 exceptions") + public void testConcurrentReadsDuringScreenResizing() throws Exception { + ScreenBuffer sb = new ScreenBuffer(24, 80, 27, 132, new EbcdicTranslator()); + int workerCount = 8; + ExecutorService executor = Executors.newFixedThreadPool(workerCount + 1); + AtomicBoolean running = new AtomicBoolean(true); + AtomicInteger exceptionCount = new AtomicInteger(0); + AtomicLong successfulReads = new AtomicLong(0); + + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch writerLatch = new CountDownLatch(1); + + for (int w = 0; w < workerCount; w++) { + executor.submit(() -> { + try { + startLatch.await(); + while (running.get()) { + ScreenBuffer.DisplaySnapshot snap = sb.getDisplaySnapshot(); + if (snap == null) { + exceptionCount.incrementAndGet(); + continue; + } + + int rows = sb.getDisplayRows(); + int cols = sb.getDisplayCols(); + int total = rows * cols; + + for (int i = 0; i < total; i++) { + ExtendedAttribute cell = sb.getDisplayCell(i); + if (cell != null) { + successfulReads.incrementAndGet(); + } + } + } + } catch (Throwable t) { + exceptionCount.incrementAndGet(); + } + }); + } + + // Writer alternates dimensions between 24x80 (1920) and 27x132 (3564) + executor.submit(() -> { + try { + startLatch.await(); + for (int iter = 0; iter < 1000; iter++) { + sb.erase((iter % 2) != 0); + } + } catch (Throwable t) { + exceptionCount.incrementAndGet(); + } finally { + writerLatch.countDown(); + } + }); + + startLatch.countDown(); + assertTrue(writerLatch.await(10, TimeUnit.SECONDS), "Dimension resizing writer must complete without deadlock"); + + running.set(false); + executor.shutdown(); + assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); + + assertEquals(0, exceptionCount.get(), "Screen resizing under reader load must not throw ArrayIndexOutOfBoundsException or NPE"); + assertTrue(successfulReads.get() > 50_000, "Concurrent readers must successfully read cells across dimension changes"); + } + + @Test + @DisplayName("Phase 29.2: setCursorAddress propagates cursor changes atomically to DisplaySnapshot") + public void testCursorUpdatePropagatesToDisplaySnapshot() { + ScreenBuffer sb = new ScreenBuffer(TerminalModel.IBM_3279_2, new EbcdicTranslator()); + sb.erase(false); + + assertEquals(0, sb.getDisplayCursorAddress()); + + sb.setCursorAddress(125); + assertEquals(125, sb.getDisplayCursorAddress()); + assertNotNull(sb.getDisplaySnapshot()); + assertEquals(125, sb.getDisplaySnapshot().cursorAddress); + + sb.setCursorPosition(5, 10); + int expected = 5 * sb.getCols() + 10; + assertEquals(expected, sb.getDisplayCursorAddress()); + assertEquals(expected, sb.getDisplaySnapshot().cursorAddress); + } +}