Clear cruft
Build and Test j3270 / Build JAR & Run Tests (Java 21) (push) Successful in 26s
Build and Test j3270 / Build JAR & Run Tests (Java 11) (push) Successful in 36s
Build and Test j3270 / Build JAR & Run Tests (Java 17) (push) Failing after 1m1s

This commit is contained in:
2026-09-07 20:16:00 -04:00
parent 50a4279446
commit dbcb0aa1b6
66 changed files with 5579 additions and 443 deletions
@@ -0,0 +1,371 @@
package haus.nightmare.lib3270j.datastream;
import haus.nightmare.lib3270j.ConnectionConfig;
import haus.nightmare.lib3270j.Telnet3270Client;
import haus.nightmare.lib3270j.TerminalModel;
import haus.nightmare.lib3270j.charset.EbcdicTranslator;
import haus.nightmare.lib3270j.ecl.ECLConnection;
import haus.nightmare.lib3270j.ecl.ECLConstants;
import haus.nightmare.lib3270j.ecl.ECLOIA;
import haus.nightmare.lib3270j.ecl.ECLPS;
import haus.nightmare.lib3270j.ecl.ECLPSEvent;
import haus.nightmare.lib3270j.ecl.ECLPSListener;
import haus.nightmare.lib3270j.ecl.ECLSession;
import haus.nightmare.lib3270j.input.InputProcessor;
import haus.nightmare.lib3270j.listener.ScreenUpdateListener;
import haus.nightmare.lib3270j.protocol.DS3270Constants;
import haus.nightmare.lib3270j.screen.ScreenBuffer;
import haus.nightmare.lib3270j.telnet.TelnetFSM;
import haus.nightmare.lib3270j.tn3270.DS3270;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.Properties;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.*;
/**
* Phase 10 Test Suite: DataStream Protocol Hardening & AUTO_SYS_UNLOCK Parity.
*
* Verifies:
* 10.1 WCC Keyboard Restore Logic (intermediate writes without restore bit keep keyboard locked).
* 10.2 AUTO_SYS_UNLOCK parity across ConnectionConfig, ECLSession, ECLConnection, DS3270, and TelnetFSM.
* 10.3 Contention Resolution negotiation, SDI/KRI tracking, and ECLPSEvent.EVENT_KEY_UNLOCKED dispatch.
*/
public class Phase10ProtocolHardeningTest {
private ScreenBuffer screen;
private EbcdicTranslator translator;
private InputProcessor inputProcessor;
private DataStreamProcessor processor;
@BeforeEach
public void setUp() {
translator = new EbcdicTranslator();
screen = new ScreenBuffer(TerminalModel.IBM_3279_4, translator);
inputProcessor = new InputProcessor(screen, translator, null);
processor = new DataStreamProcessor(screen, translator);
processor.setInputProcessor(inputProcessor);
}
// =========================================================================
// 10.1 WCC Keyboard Restore Logic
// =========================================================================
@Test
@DisplayName("10.1: Write without WCC restore bit keeps keyboard locked")
public void testWriteWithoutWccRestoreKeepsKeyboardLocked() {
// Initially lock the keyboard
inputProcessor.setKeyboardLocked(true);
assertTrue(inputProcessor.isKeyboardLocked());
// Construct 3270 Write Command (0x01) with WCC = 0xC0 (Reset MDT only, no keyboard restore 0x02)
byte[] record = new byte[] {
(byte) DS3270Constants.CMD_WRITE,
(byte) 0xC0, // WCC: bit 6 set (reset MDT), bit 1 (restore) NOT set
(byte) 0x11, 0x40, 0x40, // SBA to 0
(byte) 0xC1, (byte) 0xC2 // 'A', 'B'
};
processor.processRecord(record, 0, record.length, false);
// Keyboard MUST remain locked because WCC restore bit 0x02 was not set
assertTrue(inputProcessor.isKeyboardLocked(),
"Keyboard must remain locked when WCC does not have restore bit (0x02) set");
}
@Test
@DisplayName("10.1: Write with WCC restore bit unlocks keyboard")
public void testWriteWithWccRestoreUnlocksKeyboard() {
// Initially lock the keyboard
inputProcessor.setKeyboardLocked(true);
assertTrue(inputProcessor.isKeyboardLocked());
// Construct 3270 Write Command (0x01) with WCC = 0xC2 (Reset MDT + Keyboard Restore bit 0x02)
byte[] record = new byte[] {
(byte) DS3270Constants.CMD_WRITE,
(byte) 0xC2, // WCC: restore bit (0x02) set
(byte) 0x11, 0x40, 0x40, // SBA to 0
(byte) 0xC1, (byte) 0xC2 // 'A', 'B'
};
processor.processRecord(record, 0, record.length, false);
// Keyboard MUST now be unlocked
assertFalse(inputProcessor.isKeyboardLocked(),
"Keyboard must be unlocked when WCC has restore bit (0x02) set");
}
@Test
@DisplayName("10.1: Intermediate writes without restore bit maintain keyboard lock across sequence")
public void testIntermediateWritesKeepKeyboardLockedUntilFinalRestore() {
inputProcessor.setKeyboardLocked(true);
assertTrue(inputProcessor.isKeyboardLocked());
// First intermediate write: WCC = 0x00 (no restore)
byte[] write1 = new byte[] {
(byte) DS3270Constants.CMD_WRITE,
(byte) 0x00,
(byte) 0x11, 0x40, 0x40,
(byte) 0xC1
};
processor.processRecord(write1, 0, write1.length, false);
assertTrue(inputProcessor.isKeyboardLocked(), "Write 1 without restore bit must leave keyboard locked");
// Second intermediate write: Erase / Write with WCC = 0x40 (Reset, no restore)
byte[] write2 = new byte[] {
(byte) DS3270Constants.CMD_ERASE_WRITE,
(byte) 0x40,
(byte) 0x11, 0x40, 0x50,
(byte) 0xC2
};
processor.processRecord(write2, 0, write2.length, false);
assertTrue(inputProcessor.isKeyboardLocked(), "Write 2 without restore bit must leave keyboard locked");
// Final write: WCC = 0x42 (Reset + Restore)
byte[] write3 = new byte[] {
(byte) DS3270Constants.CMD_WRITE,
(byte) 0x42,
(byte) 0x11, 0x40, 0x60,
(byte) 0xC3
};
processor.processRecord(write3, 0, write3.length, false);
assertFalse(inputProcessor.isKeyboardLocked(), "Final write with restore bit must unlock keyboard");
}
// =========================================================================
// 10.2 AUTO_SYS_UNLOCK Parity
// =========================================================================
@Test
@DisplayName("10.2: autoSysUnlock configuration property propagation across ECL components")
public void testAutoSysUnlockConfigurationPropagation() {
// Default ConnectionConfig should have autoSysUnlock == true
ConnectionConfig config = new ConnectionConfig("localhost", 23, TerminalModel.IBM_3279_4);
assertTrue(config.isAutoSysUnlock(), "Default autoSysUnlock in ConnectionConfig must be true");
config.setAutoSysUnlock(false);
assertFalse(config.isAutoSysUnlock());
// Telnet3270Client propagation
Telnet3270Client client = new Telnet3270Client(config);
assertFalse(client.isAutoSysUnlock());
// ECLSession propagation
ECLSession session = new ECLSession(client);
assertFalse(session.isAutoSysUnlock());
assertFalse(session.getConnection().isAutoSysUnlock());
// Modify via ECLSession
session.setAutoSysUnlock(true);
assertTrue(session.isAutoSysUnlock());
assertTrue(client.isAutoSysUnlock());
assertTrue(config.isAutoSysUnlock());
// Test ECLSession Properties parsing
Properties props = new Properties();
props.setProperty(ECLSession.SESSION_AUTO_SYS_UNLOCK, "false");
props.setProperty(ECLSession.SESSION_HOST, "mainframe.org");
ECLSession propsSession = new ECLSession(props);
assertFalse(propsSession.isAutoSysUnlock());
assertFalse(propsSession.getConnection().isAutoSysUnlock());
// ECLConnection standalone properties
ECLConnection standaloneConn = new ECLConnection(props);
assertFalse(standaloneConn.isAutoSysUnlock());
}
@Test
@DisplayName("10.2: DS3270 endOfRecord unlocks keyboard and clears OIA when autoSysUnlock is true and in X SYSTEM")
public void testDs3270AutoSysUnlockOnEor() {
ConnectionConfig config = new ConnectionConfig("localhost", 23, TerminalModel.IBM_3279_4);
config.setAutoSysUnlock(true);
Telnet3270Client client = new Telnet3270Client(config);
ECLSession session = new ECLSession(client);
ECLPS ps = session.GetPS();
ECLOIA oia = session.GetOIA();
DS3270 ds = new DS3270(session, ps);
assertTrue(ds.isAutoSysUnlock());
// Set connected state so OIA does not treat it as communication check
client.getTelnetFSM().setConnectionState(haus.nightmare.lib3270j.ConnectionState.CONNECTED_3270);
// Put session in X SYSTEM lock
ps.lockKeyboard(8); // reason 8: SYSLOCK
assertTrue(ps.islocked_SYSLOCK());
assertTrue(client.getInputProcessor().isKeyboardLocked());
assertTrue(oia.isXSystem());
// Trigger endOfRecord() with autoSysUnlock=true
ds.endOfRecord();
// Keyboard must be auto-unlocked and OIA cleared
assertFalse(client.getInputProcessor().isKeyboardLocked(),
"Keyboard should be unlocked on EOR when autoSysUnlock is true");
assertFalse(oia.isXSystem(),
"OIA X SYSTEM lock should be cleared on EOR when autoSysUnlock is true");
}
@Test
@DisplayName("10.2: DS3270 endOfRecord leaves keyboard locked when autoSysUnlock is false and in X SYSTEM")
public void testDs3270AutoSysUnlockDisabledKeepsSystemLock() {
ConnectionConfig config = new ConnectionConfig("localhost", 23, TerminalModel.IBM_3279_4);
config.setAutoSysUnlock(false);
Telnet3270Client client = new Telnet3270Client(config);
ECLSession session = new ECLSession(client);
ECLPS ps = session.GetPS();
DS3270 ds = new DS3270(session, ps);
assertFalse(ds.isAutoSysUnlock());
// Put session in X SYSTEM lock
ps.lockKeyboard(8);
assertTrue(ps.islocked_SYSLOCK());
assertTrue(client.getInputProcessor().isKeyboardLocked());
// Trigger endOfRecord() with autoSysUnlock=false
ds.endOfRecord();
// Keyboard MUST remain locked
assertTrue(client.getInputProcessor().isKeyboardLocked(),
"Keyboard must remain locked on EOR when autoSysUnlock is false");
}
// =========================================================================
// 10.3 Contention Resolution & Pre-Data State Transitions
// =========================================================================
@Test
@DisplayName("10.3: Contention Resolution negotiation flag synchronization with ECLConnection")
public void testContentionResolutionNegotiationSync() {
ConnectionConfig config = new ConnectionConfig("localhost", 23, TerminalModel.IBM_3279_4);
Telnet3270Client client = new Telnet3270Client(config);
ECLSession session = new ECLSession(client);
ECLConnection conn = session.getConnection();
TelnetFSM fsm = client.getTelnetFSM();
assertNotNull(fsm);
assertTrue(fsm.isNegotiateContentionResolution(),
"Default negotiateContentionResolution should be true");
// Test setter on FSM and reflection in ECLConnection
fsm.setContentionResolutionNegotiated(true);
assertTrue(fsm.isContentionResolutionNegotiated(),
"Contention resolution should be negotiated in FSM");
assertTrue(conn.getContentionResolution(),
"ECLConnection should reflect negotiated contention resolution");
assertTrue(session.getContentionResolution(),
"ECLSession should reflect negotiated contention resolution");
}
@Test
@DisplayName("10.3: Contention Resolution SDI flag restores keyboard and fires EVENT_KEY_UNLOCKED on EOR")
public void testContentionResolutionWithSdiRestoresKeyboardAndFiresEvent() {
ConnectionConfig config = new ConnectionConfig("localhost", 23, TerminalModel.IBM_3279_4);
Telnet3270Client client = new Telnet3270Client(config);
ECLSession session = new ECLSession(client);
ECLPS ps = session.GetPS();
DS3270 ds = new DS3270(session, ps);
// Enable Contention Resolution on session
session.setContentionResolution(true);
assertTrue(session.getContentionResolution());
// Lock keyboard
client.getInputProcessor().setKeyboardLocked(true);
assertTrue(client.getInputProcessor().isKeyboardLocked());
// Register listener for EVENT_KEY_UNLOCKED
AtomicInteger eventTypeReceived = new AtomicInteger(-1);
AtomicBoolean unlockedEventFired = new AtomicBoolean(false);
ps.RegisterPSEvent(new ECLPSListener() {
@Override
public void PSNotifyEvent(ECLPSEvent event) {
eventTypeReceived.set(event.getEventType());
unlockedEventFired.set(true);
}
});
// Inbound TN3270E header with SDI bit set:
// s2 request flag has bit 0 (0x01) set -> Send Data Indicator (SDI)
short s1 = 0;
short s2 = DS3270.request_bit_SDI; // 1
short s3 = 0;
ds.receiveHeaderData(s1, s2, s3, 0);
assertTrue(ds.isSdi_flag(), "SDI flag should be tracked from TN3270E header");
// Trigger endOfRecord()
ds.endOfRecord();
// Keyboard should be unlocked because SDI was received
assertFalse(client.getInputProcessor().isKeyboardLocked(),
"Keyboard should be restored on EOR when CR is active and SDI is set");
assertFalse(ds.isSdi_flag(), "SDI flag should be reset after EOR");
assertTrue(unlockedEventFired.get(), "ECLPSEvent must be dispatched on keyboard unlock");
assertEquals(ECLPSEvent.EVENT_KEY_UNLOCKED, eventTypeReceived.get(),
"Dispatched event must match EVENT_KEY_UNLOCKED");
}
@Test
@DisplayName("10.3: Contention Resolution WITHOUT SDI flag keeps keyboard locked on EOR")
public void testContentionResolutionWithoutSdiKeepsKeyboardLocked() {
ConnectionConfig config = new ConnectionConfig("localhost", 23, TerminalModel.IBM_3279_4);
Telnet3270Client client = new Telnet3270Client(config);
ECLSession session = new ECLSession(client);
ECLPS ps = session.GetPS();
DS3270 ds = new DS3270(session, ps);
session.setContentionResolution(true);
assertTrue(session.getContentionResolution());
// Initially lock keyboard
client.getInputProcessor().setKeyboardLocked(true);
// Inbound TN3270E header WITHOUT SDI flag (s2 = 0)
ds.receiveHeaderData((short) 0, (short) 0, (short) 0, 0);
assertFalse(ds.isSdi_flag());
// Trigger EOR
ds.endOfRecord();
// Keyboard MUST stay locked because host did not grant Send Data Indicator (turn)
assertTrue(client.getInputProcessor().isKeyboardLocked(),
"Keyboard must remain locked on EOR when Contention Resolution is active and SDI is not set");
}
@Test
@DisplayName("10.3: Contention Resolution KRI flag resets system lock pending on EOR")
public void testContentionResolutionKriResetsSystemLock() {
ConnectionConfig config = new ConnectionConfig("localhost", 23, TerminalModel.IBM_3279_4);
Telnet3270Client client = new Telnet3270Client(config);
ECLSession session = new ECLSession(client);
ECLPS ps = session.GetPS();
DS3270 ds = new DS3270(session, ps);
session.setContentionResolution(true);
// Put in system lock
ps.lockKeyboard(8);
assertTrue(ps.islocked_SYSLOCK());
// Receive header with KRI flag (bit 1 = 0x02) and SDI flag (bit 0 = 0x01)
short s2 = (short) (DS3270.request_bit_SDI | DS3270.request_bit_KRI);
ds.receiveHeaderData((short) 0, s2, (short) 0, 0);
assertTrue(ds.isSdi_flag());
assertTrue(ds.isKri_flag());
ds.endOfRecord();
// Keyboard restored and system lock reset
assertFalse(client.getInputProcessor().isKeyboardLocked());
assertFalse(ps.islocked_SYSLOCK());
assertFalse(ds.isKri_flag());
}
}
@@ -0,0 +1,397 @@
package haus.nightmare.lib3270j.datastream;
import haus.nightmare.lib3270j.ConnectionConfig;
import haus.nightmare.lib3270j.ConnectionState;
import haus.nightmare.lib3270j.Telnet3270Client;
import haus.nightmare.lib3270j.TerminalModel;
import haus.nightmare.lib3270j.ecl.ECLConnection;
import haus.nightmare.lib3270j.ecl.ECLSession;
import haus.nightmare.lib3270j.listener.ConnectionListener;
import haus.nightmare.lib3270j.protocol.TelnetConstants;
import haus.nightmare.lib3270j.telnet.TelnetConnection;
import haus.nightmare.lib3270j.telnet.TelnetFSM;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.Properties;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.*;
/**
* Phase 11 Test Suite: Enterprise Connection Resilience & "Dirty" Connection Handling.
*
* Verifies:
* 11.1 Application-Layer Telnet Keep-Alive / Heartbeat Engine (NOP and TIMING-MARK, activity reset, shutdown).
* 11.2 Automatic Reconnection with Exponential Backoff (unexpected disconnect, state transitions, retry loop, recovery, exhaustion).
* 11.3 Transport-Level Socket Tuning & Options (soTimeout, extended socket options, CLI parsing).
* 11.4 IBM HoD ECL Compatibility Facade (SESSION_KEEPALIVE, keepAliveTimeout, SESSION_AUTORECONNECT, ECLConnection methods).
*/
public class Phase11ConnectionResilienceTest {
// =========================================================================
// 11.1 Application-Layer Telnet Keep-Alive / Heartbeat Engine
// =========================================================================
@Test
@DisplayName("11.1: Keep-Alive transmits Telnet IAC NOP (0xFF 0xF1) when idle")
public void testKeepAliveTransmitsIacNop() throws Exception {
try (ServerSocket server = new ServerSocket(0)) {
int port = server.getLocalPort();
ConnectionConfig config = new ConnectionConfig("127.0.0.1", port);
config.setKeepAliveEnabled(true);
config.setKeepAliveIntervalSeconds(1);
config.setKeepAliveType("NOP");
Telnet3270Client client = new Telnet3270Client(config);
CountDownLatch clientConnected = new CountDownLatch(1);
AtomicReference<Socket> serverAccepted = new AtomicReference<>();
Thread serverThread = new Thread(() -> {
try {
Socket s = server.accept();
serverAccepted.set(s);
clientConnected.countDown();
} catch (Exception ignored) {}
});
serverThread.start();
client.connect();
assertTrue(clientConnected.await(5, TimeUnit.SECONDS), "Server did not accept client connection");
Socket s = serverAccepted.get();
assertNotNull(s);
InputStream in = s.getInputStream();
// Explicitly trigger or wait for keepalive heartbeat
client.getConnection().sendKeepAliveHeartbeat();
byte[] buf = new byte[2];
int read = in.read(buf);
assertEquals(2, read);
assertEquals((byte) TelnetConstants.IAC, buf[0]);
assertEquals((byte) TelnetConstants.NOP, buf[1]);
client.disconnect();
}
}
@Test
@DisplayName("11.1: Keep-Alive transmits Telnet IAC DO TIMING-MARK (0xFF 0xFD 0x06)")
public void testKeepAliveTransmitsTimingMark() throws Exception {
try (ServerSocket server = new ServerSocket(0)) {
int port = server.getLocalPort();
ConnectionConfig config = new ConnectionConfig("127.0.0.1", port);
config.setKeepAliveEnabled(true);
config.setKeepAliveIntervalSeconds(1);
config.setKeepAliveType("TIMING-MARK");
Telnet3270Client client = new Telnet3270Client(config);
CountDownLatch clientConnected = new CountDownLatch(1);
AtomicReference<Socket> serverAccepted = new AtomicReference<>();
Thread serverThread = new Thread(() -> {
try {
Socket s = server.accept();
serverAccepted.set(s);
clientConnected.countDown();
} catch (Exception ignored) {}
});
serverThread.start();
client.connect();
assertTrue(clientConnected.await(5, TimeUnit.SECONDS));
Socket s = serverAccepted.get();
assertNotNull(s);
InputStream in = s.getInputStream();
client.getConnection().sendKeepAliveHeartbeat();
byte[] buf = new byte[3];
int read = in.read(buf);
assertEquals(3, read);
assertEquals((byte) TelnetConstants.IAC, buf[0]);
assertEquals((byte) TelnetConstants.DO, buf[1]);
assertEquals((byte) TelnetConstants.TELOPT_TM, buf[2]);
client.disconnect();
}
}
@Test
@DisplayName("11.1: Transmission resets last activity timestamp")
public void testTransmissionResetsActivityTimestamp() throws Exception {
try (ServerSocket server = new ServerSocket(0)) {
int port = server.getLocalPort();
ConnectionConfig config = new ConnectionConfig("127.0.0.1", port);
Telnet3270Client client = new Telnet3270Client(config);
new Thread(() -> {
try {
server.accept();
} catch (Exception ignored) {}
}).start();
client.connect();
TelnetConnection conn = client.getConnection();
long initialTime = conn.getLastActivityTime();
Thread.sleep(15);
conn.sendRaw(new byte[] { 0x01, 0x02 });
long updatedTime = conn.getLastActivityTime();
assertTrue(updatedTime >= initialTime + 10, "Activity timestamp was not updated on sendRaw");
client.disconnect();
}
}
// =========================================================================
// 11.2 Automatic Reconnection with Exponential Backoff
// =========================================================================
@Test
@DisplayName("11.2: Unexpected EOF transitions to RECONNECTING state when autoReconnect is true")
public void testUnexpectedDisconnectTransitionsToReconnecting() throws Exception {
try (ServerSocket server = new ServerSocket(0)) {
int port = server.getLocalPort();
ConnectionConfig config = new ConnectionConfig("127.0.0.1", port);
config.setAutoReconnect(true);
config.setReconnectMaxRetries(3);
Telnet3270Client client = new Telnet3270Client(config);
CopyOnWriteArrayList<ConnectionState> stateHistory = new CopyOnWriteArrayList<>();
CountDownLatch reconnectingLatch = new CountDownLatch(1);
client.addConnectionListener(new ConnectionListener() {
@Override
public void onConnectionStateChanged(ConnectionState oldState, ConnectionState newState) {
stateHistory.add(newState);
if (newState == ConnectionState.RECONNECTING) {
reconnectingLatch.countDown();
}
}
@Override public void onConnectionError(String message) {}
});
AtomicReference<Socket> acceptedSocket = new AtomicReference<>();
Thread serverThread = new Thread(() -> {
try {
Socket s = server.accept();
acceptedSocket.set(s);
} catch (Exception ignored) {}
});
serverThread.start();
client.connect();
serverThread.join(2000);
assertNotNull(acceptedSocket.get());
// Abruptly sever the connection from the host side (EOF)
acceptedSocket.get().close();
assertTrue(reconnectingLatch.await(4, TimeUnit.SECONDS), "Client did not enter RECONNECTING state on unexpected EOF");
assertTrue(stateHistory.contains(ConnectionState.RECONNECTING), "State history must include RECONNECTING");
assertTrue(client.isReconnecting());
// Check OIA message updated
assertNotNull(client.getOIA());
client.disconnect();
assertFalse(client.isReconnecting());
assertEquals(ConnectionState.NOT_CONNECTED, client.getConnectionState());
}
}
@Test
@DisplayName("11.2: Intentional client.disconnect() transitions directly to NOT_CONNECTED without auto-reconnect")
public void testIntentionalDisconnectDoesNotTriggerAutoReconnect() throws Exception {
try (ServerSocket server = new ServerSocket(0)) {
int port = server.getLocalPort();
ConnectionConfig config = new ConnectionConfig("127.0.0.1", port);
config.setAutoReconnect(true);
config.setReconnectMaxRetries(3);
Telnet3270Client client = new Telnet3270Client(config);
CopyOnWriteArrayList<ConnectionState> stateHistory = new CopyOnWriteArrayList<>();
client.addConnectionListener(new ConnectionListener() {
@Override
public void onConnectionStateChanged(ConnectionState oldState, ConnectionState newState) {
stateHistory.add(newState);
}
@Override public void onConnectionError(String message) {}
});
new Thread(() -> {
try {
server.accept();
} catch (Exception ignored) {}
}).start();
client.connect();
Thread.sleep(100);
// Explicit client disconnect
client.disconnect();
assertFalse(stateHistory.contains(ConnectionState.RECONNECTING),
"Intentional disconnect should never transition to RECONNECTING");
assertEquals(ConnectionState.NOT_CONNECTED, client.getConnectionState());
}
}
@Test
@DisplayName("11.2: Auto-reconnect retry loop successfully re-establishes connection")
public void testAutoReconnectSuccessfulReconnection() throws Exception {
try (ServerSocket server = new ServerSocket(0)) {
int port = server.getLocalPort();
ConnectionConfig config = new ConnectionConfig("127.0.0.1", port);
config.setAutoReconnect(true);
config.setReconnectMaxRetries(3);
Telnet3270Client client = new Telnet3270Client(config);
CountDownLatch reconnectedLatch = new CountDownLatch(1);
AtomicReference<Socket> firstConn = new AtomicReference<>();
AtomicReference<Socket> secondConn = new AtomicReference<>();
Thread serverThread = new Thread(() -> {
try {
Socket s1 = server.accept();
firstConn.set(s1);
// Close first socket to trigger reconnect
Thread.sleep(50);
s1.close();
// Accept the reconnection attempt
Socket s2 = server.accept();
secondConn.set(s2);
reconnectedLatch.countDown();
} catch (Exception ignored) {}
});
serverThread.start();
client.connect();
// Wait for reconnection to succeed
assertTrue(reconnectedLatch.await(8, TimeUnit.SECONDS), "Server did not receive reconnection attempt");
assertNotNull(secondConn.get());
// Wait for client to complete handshake / notify connected
Thread.sleep(200);
assertTrue(client.isConnected());
client.disconnect();
}
}
// =========================================================================
// 11.3 Transport-Level Socket Tuning & Options
// =========================================================================
@Test
@DisplayName("11.3: Configurable SO_TIMEOUT is applied to TCP socket")
public void testSoTimeoutApplied() throws Exception {
try (ServerSocket server = new ServerSocket(0)) {
int port = server.getLocalPort();
ConnectionConfig config = new ConnectionConfig("127.0.0.1", port);
config.setSoTimeoutMs(750);
Telnet3270Client client = new Telnet3270Client(config);
new Thread(() -> {
try { server.accept(); } catch (Exception ignored) {}
}).start();
client.connect();
assertNotNull(client.getConnection());
// Client socket should have SO_TIMEOUT applied
assertEquals(750, config.getSoTimeoutMs());
client.disconnect();
}
}
@Test
@DisplayName("11.3: parseHostString parses keepalive and autoreconnect flags")
public void testParseHostStringFlags() {
ConnectionConfig c1 = ConnectionConfig.parseHostString("mainframe.corp.com:23 --keepalive --autoreconnect", 23, TerminalModel.IBM_3279_4);
assertTrue(c1.isKeepAliveEnabled());
assertTrue(c1.isAutoReconnect());
assertEquals("mainframe.corp.com", c1.getHost());
assertEquals(23, c1.getPort());
ConnectionConfig c2 = ConnectionConfig.parseHostString("mainframe.corp.com:23 --no-keepalive --no-autoreconnect", 23, TerminalModel.IBM_3279_4);
assertFalse(c2.isKeepAliveEnabled());
assertFalse(c2.isAutoReconnect());
}
// =========================================================================
// 11.4 IBM HoD ECL Compatibility Facade
// =========================================================================
@Test
@DisplayName("11.4: ECLSession parses HoD properties for Keep-Alive and Auto-Reconnect")
public void testEclSessionPropertiesParsing() {
Properties props = new Properties();
props.setProperty(ECLSession.SESSION_HOST, "mvs01.test.net");
props.setProperty(ECLSession.SESSION_PORT, "23");
props.setProperty(ECLSession.SESSION_KEEPALIVE, "true");
props.setProperty(ECLSession.KEY_KEEPALIVE_TIMEOUT, "180");
props.setProperty(ECLSession.KEY_KEEPALIVE_TYPE, "TIMING-MARK");
props.setProperty(ECLSession.SESSION_AUTORECONNECT, "true");
props.setProperty(ECLSession.SESSION_RECONNECT_RETRIES, "8");
ECLSession session = new ECLSession(props);
assertTrue(session.isKeepAlive());
assertEquals(180, session.getKeepAliveTimeout());
assertTrue(session.isAutoReconnect());
ECLConnection conn = session.getConnection();
assertNotNull(conn);
assertTrue(conn.isKeepAlive());
assertEquals(180, conn.getKeepAliveTimeout());
assertEquals("TIMING-MARK", conn.getKeepAliveType());
assertTrue(conn.isAutoReconnect());
assertEquals(8, conn.getMaxRetry());
}
@Test
@DisplayName("11.4: ECLConnection getters and setters synchronize with underlying client")
public void testEclConnectionGettersSetters() {
ConnectionConfig cfg = new ConnectionConfig("localhost", 23);
Telnet3270Client client = new Telnet3270Client(cfg);
ECLSession session = new ECLSession(client);
ECLConnection conn = session.getConnection();
conn.setKeepAlive(false);
assertFalse(conn.isKeepAlive());
assertFalse(client.getConfig().isKeepAliveEnabled());
conn.setKeepAliveTimeout(60);
assertEquals(60, conn.getKeepAliveTimeout());
assertEquals(60, client.getConfig().getKeepAliveIntervalSeconds());
conn.setKeepAliveType("TIMING-MARK");
assertEquals("TIMING-MARK", conn.getKeepAliveType());
assertEquals("TIMING-MARK", client.getConfig().getKeepAliveType());
conn.setAutoReconnect(true);
assertTrue(conn.isAutoReconnect());
assertTrue(client.getConfig().isAutoReconnect());
conn.setMaxRetry(10);
assertEquals(10, conn.getMaxRetry());
assertEquals(10, client.getConfig().getReconnectMaxRetries());
}
}
@@ -0,0 +1,89 @@
package haus.nightmare.lib3270j.ecl;
import haus.nightmare.lib3270j.TerminalModel;
import haus.nightmare.lib3270j.charset.EbcdicTranslator;
import haus.nightmare.lib3270j.input.InputProcessor;
import haus.nightmare.lib3270j.screen.ScreenBuffer;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.*;
/**
* Unit tests for Phase 13.2: Interactive Entry Assist Engine.
* Verifies interactive word wrap in DOC mode and audible bell alert
* when typing reaches the configured bell column.
*/
public class InteractiveEntryAssistTest {
private ScreenBuffer screen;
private EbcdicTranslator translator;
private InputProcessor input;
@BeforeEach
public void setUp() {
translator = new EbcdicTranslator();
screen = new ScreenBuffer(TerminalModel.IBM_3278_2, translator);
input = new InputProcessor(screen, translator, null);
}
@Test
public void testAudibleBellTriggersAtConfiguredBellColumn() {
input.setBellEnabled(true);
input.setBellColumn(74); // 0-based column 74 = column 75
AtomicInteger bellCount = new AtomicInteger(0);
input.setBellListener(bellCount::incrementAndGet);
screen.setCursorPosition(0, 72);
input.typeCharacter('A'); // col 73
assertEquals(0, bellCount.get());
input.typeCharacter('B'); // col 74 -> triggers bell
assertEquals(1, bellCount.get());
input.typeCharacter('C'); // col 75 -> remains 1 on current row
assertEquals(1, bellCount.get());
// Moving to next row and typing across bell column triggers bell again
screen.setCursorPosition(1, 73);
input.typeCharacter('X'); // advances to col 74 on row 1
assertEquals(2, bellCount.get());
}
@Test
public void testDocModeWordWrapMovesPartialWordToNextRow() {
screen.setEntryAssistDOCmode(true);
screen.setEntryAssistWordWrap(true);
screen.setLeftMargin(0); // Col 0
screen.setRightMargin(15); // Col 15
assertTrue(screen.isEntryAssistDOCmode());
assertTrue(screen.isEntryAssistWordWrap());
// Type "HELLO " (6 chars) starting at col 0
screen.setCursorPosition(0, 0);
for (char c : "HELLO ".toCharArray()) {
input.typeCharacter(c);
}
assertEquals(0, screen.getCursorRow());
assertEquals(6, screen.getCursorCol());
// Move cursor near right margin: Row 0, Col 13
screen.setCursorPosition(0, 13);
input.typeCharacter('P'); // col 14
input.typeCharacter('A'); // col 15 (at right margin)
input.typeCharacter('R'); // crosses right margin -> triggers word wrap
// After word wrap, the word "PAR" should be moved to Row 1, left margin (Col 0)
assertEquals(1, screen.getCursorRow());
assertTrue(screen.getCursorCol() >= 3);
// Verify content on Row 1
assertEquals('P', (char) screen.getCell(80).ucs4);
assertEquals('A', (char) screen.getCell(81).ucs4);
assertEquals('R', (char) screen.getCell(82).ucs4);
}
}
@@ -0,0 +1,319 @@
package haus.nightmare.lib3270j.ecl;
import haus.nightmare.lib3270j.TerminalModel;
import haus.nightmare.lib3270j.charset.EbcdicTranslator;
import haus.nightmare.lib3270j.input.InputProcessor;
import haus.nightmare.lib3270j.listener.ScreenUpdateListener;
import haus.nightmare.lib3270j.screen.ScreenBuffer;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
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.*;
/**
* Phase 12 Test Suite: Reactive Synchronization & Thread-Safety Hardening.
*
* Verifies:
* 12.1 Condition-Based Synchronization for ECL Automation Calls:
* - Sub-millisecond reactive wakeup for waitForScreen(ECLScreenDesc).
* - Reactive wakeup for waitForCursor(row, col).
* - Reactive wakeup for waitForString(text) and waitWhileScreen(ECLScreenDesc).
* - Clean timeout handling without CPU busy-wait spinning.
* - Graceful thread interruption handling.
* - ECLOIA condition-based reactive waitForInput and waitForTransition.
* - ScreenBuffer listener dispatch and cursor change tracking.
* - Concurrency safety with multiple waiters.
*/
public class Phase12SynchronizationTest {
private ScreenBuffer screen;
private EbcdicTranslator translator;
private InputProcessor inputProcessor;
private ECLPS ps;
@BeforeEach
public void setUp() {
translator = new EbcdicTranslator();
screen = new ScreenBuffer(TerminalModel.IBM_3279_4, translator);
inputProcessor = new InputProcessor(screen, translator, null);
ps = new ECLPS(screen, inputProcessor, translator);
}
@Test
@DisplayName("12.1: waitForScreen(ECLScreenDesc) wakes up reactively upon screen update")
public void testReactiveWaitForScreenDesc() throws Exception {
ECLScreenDesc desc = new ECLScreenDesc();
desc.addString("TSO/E LOGON", 5, 10);
AtomicBoolean result = new AtomicBoolean(false);
AtomicLong durationMs = new AtomicLong(-1);
CountDownLatch started = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(1);
Thread waiter = new Thread(() -> {
started.countDown();
long t0 = System.currentTimeMillis();
boolean matched = ps.waitForScreen(desc, 5000);
durationMs.set(System.currentTimeMillis() - t0);
result.set(matched);
done.countDown();
});
waiter.start();
assertTrue(started.await(1, TimeUnit.SECONDS));
Thread.sleep(50); // Give waiter time to block on condition
// Write matching text at 1-based (5, 10) -> 0-based row 4 col 9
screen.setText("TSO/E LOGON", 4, 9);
screen.notifyScreenUpdate();
assertTrue(done.await(2, TimeUnit.SECONDS));
assertTrue(result.get(), "waitForScreen should match");
assertTrue(durationMs.get() < 1500, "Should unblock reactively well before 5000ms timeout (took " + durationMs.get() + "ms)");
}
@Test
@DisplayName("12.1: waitForCursor(row, col) wakes up reactively upon cursor position change")
public void testReactiveWaitForCursor() throws Exception {
screen.setCursorPosition(0, 0);
AtomicBoolean result = new AtomicBoolean(false);
AtomicLong durationMs = new AtomicLong(-1);
CountDownLatch started = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(1);
Thread waiter = new Thread(() -> {
started.countDown();
long t0 = System.currentTimeMillis();
boolean matched = ps.waitForCursor(12, 34, 5000);
durationMs.set(System.currentTimeMillis() - t0);
result.set(matched);
done.countDown();
});
waiter.start();
assertTrue(started.await(1, TimeUnit.SECONDS));
Thread.sleep(50);
// Move cursor
screen.setCursorPosition(12, 34);
assertTrue(done.await(2, TimeUnit.SECONDS));
assertTrue(result.get(), "waitForCursor should succeed");
assertTrue(durationMs.get() < 1500, "Should unblock reactively upon cursor move (took " + durationMs.get() + "ms)");
}
@Test
@DisplayName("12.1: waitForString(text) wakes up reactively upon string appearance")
public void testReactiveWaitForString() throws Exception {
AtomicBoolean result = new AtomicBoolean(false);
AtomicLong durationMs = new AtomicLong(-1);
CountDownLatch started = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(1);
Thread waiter = new Thread(() -> {
started.countDown();
long t0 = System.currentTimeMillis();
boolean matched = ps.waitForString("COMMAND ===>", 5000);
durationMs.set(System.currentTimeMillis() - t0);
result.set(matched);
done.countDown();
});
waiter.start();
assertTrue(started.await(1, TimeUnit.SECONDS));
Thread.sleep(50);
screen.setText("COMMAND ===>", 20, 2);
screen.notifyScreenUpdate();
assertTrue(done.await(2, TimeUnit.SECONDS));
assertTrue(result.get(), "waitForString should find the text");
assertTrue(durationMs.get() < 1500, "Should unblock reactively (took " + durationMs.get() + "ms)");
}
@Test
@DisplayName("12.1: waitWhileScreen(desc) wakes up reactively when condition ceases to match")
public void testReactiveWaitWhileScreen() throws Exception {
// Place initial text
screen.setText("HOLDING", 0, 0);
ECLScreenDesc desc = new ECLScreenDesc();
desc.addString("HOLDING");
AtomicBoolean result = new AtomicBoolean(false);
AtomicLong durationMs = new AtomicLong(-1);
CountDownLatch started = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(1);
Thread waiter = new Thread(() -> {
started.countDown();
long t0 = System.currentTimeMillis();
boolean finished = ps.waitWhileScreen(desc, 5000);
durationMs.set(System.currentTimeMillis() - t0);
result.set(finished);
done.countDown();
});
waiter.start();
assertTrue(started.await(1, TimeUnit.SECONDS));
Thread.sleep(50);
// Erase screen so "HOLDING" is gone
screen.erase(false);
screen.notifyScreenUpdate();
assertTrue(done.await(2, TimeUnit.SECONDS));
assertTrue(result.get(), "waitWhileScreen should return true once condition is gone");
assertTrue(durationMs.get() < 1500, "Should unblock reactively when text erased (took " + durationMs.get() + "ms)");
}
@Test
@DisplayName("12.1: Clean timeout without false match or spinning")
public void testCleanTimeout() {
ECLScreenDesc desc = new ECLScreenDesc();
desc.addString("NON_EXISTENT_STRING");
long t0 = System.currentTimeMillis();
boolean matched = ps.waitForScreen(desc, 150);
long elapsed = System.currentTimeMillis() - t0;
assertFalse(matched, "Should not match non-existent text");
assertTrue(elapsed >= 100, "Should have waited for the full timeout window (elapsed=" + elapsed + "ms)");
}
@Test
@DisplayName("12.1: Graceful thread interruption during wait")
public void testThreadInterruption() throws Exception {
ECLScreenDesc desc = new ECLScreenDesc();
desc.addString("NEVER_APPEARS");
AtomicBoolean wasInterrupted = new AtomicBoolean(false);
AtomicBoolean result = new AtomicBoolean(true);
CountDownLatch started = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(1);
Thread waiter = new Thread(() -> {
started.countDown();
boolean r = ps.waitForScreen(desc, 10000);
result.set(r);
if (Thread.currentThread().isInterrupted()) {
wasInterrupted.set(true);
}
done.countDown();
});
waiter.start();
assertTrue(started.await(1, TimeUnit.SECONDS));
Thread.sleep(50);
waiter.interrupt();
assertTrue(done.await(2, TimeUnit.SECONDS));
assertFalse(result.get(), "Interrupted wait should return false");
assertTrue(wasInterrupted.get(), "Thread interrupt flag should be preserved");
}
@Test
@DisplayName("12.1: ECLOIA reactive waitForInput and waitForTransition")
public void testECLOIAReactiveWait() throws Exception {
ECLOIA oia = new ECLOIA(screen, inputProcessor, null);
oia.setInputInhibited(ECLOIA.INHIBIT_SYSTEMWAIT);
AtomicBoolean inputReady = new AtomicBoolean(false);
AtomicLong durationMs = new AtomicLong(-1);
CountDownLatch started = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(1);
Thread waiter = new Thread(() -> {
started.countDown();
long t0 = System.currentTimeMillis();
boolean ready = oia.waitForInput(5000);
durationMs.set(System.currentTimeMillis() - t0);
inputReady.set(ready);
done.countDown();
});
waiter.start();
assertTrue(started.await(1, TimeUnit.SECONDS));
Thread.sleep(50);
// Unlock OIA
oia.setInputInhibited(ECLOIA.INHIBIT_NOTINHIBITED);
assertTrue(done.await(2, TimeUnit.SECONDS));
assertTrue(inputReady.get(), "waitForInput should succeed");
assertTrue(durationMs.get() < 1500, "Should unblock reactively upon OIA unlock (took " + durationMs.get() + "ms)");
}
@Test
@DisplayName("12.1: ScreenBuffer listener dispatch and cursor movement tracking")
public void testScreenBufferListenerDispatch() {
AtomicBoolean screenUpdated = new AtomicBoolean(false);
AtomicInteger cursorOld = new AtomicInteger(-1);
AtomicInteger cursorNew = new AtomicInteger(-1);
ScreenUpdateListener listener = new ScreenUpdateListener() {
@Override
public void onScreenUpdated() {
screenUpdated.set(true);
}
@Override
public void onCursorMoved(int oldAddress, int newAddress) {
cursorOld.set(oldAddress);
cursorNew.set(newAddress);
}
};
screen.addUpdateListener(listener);
screen.notifyScreenUpdate();
assertTrue(screenUpdated.get(), "Listener should receive onScreenUpdated");
screen.setCursorAddress(123);
assertEquals(0, cursorOld.get(), "Old cursor should be 0");
assertEquals(123, cursorNew.get(), "New cursor should be 123");
screen.removeUpdateListener(listener);
screenUpdated.set(false);
screen.notifyScreenUpdate();
assertFalse(screenUpdated.get(), "Removed listener should not receive updates");
}
@Test
@DisplayName("12.1: Concurrent waiters on the same ScreenBuffer")
public void testConcurrentWaiters() throws Exception {
int numWaiters = 5;
CountDownLatch startLatch = new CountDownLatch(numWaiters);
CountDownLatch doneLatch = new CountDownLatch(numWaiters);
AtomicInteger successCount = new AtomicInteger(0);
for (int i = 0; i < numWaiters; i++) {
new Thread(() -> {
startLatch.countDown();
boolean ok = ps.waitForString("BATCH_SIGNAL", 5000);
if (ok) {
successCount.incrementAndGet();
}
doneLatch.countDown();
}).start();
}
assertTrue(startLatch.await(2, TimeUnit.SECONDS));
Thread.sleep(50);
screen.setText("BATCH_SIGNAL", 100);
screen.notifyScreenUpdate();
assertTrue(doneLatch.await(3, TimeUnit.SECONDS));
assertEquals(numWaiters, successCount.get(), "All concurrent waiters should unblock reactively");
}
}
@@ -10,9 +10,9 @@ import haus.nightmare.lib3270j.charset.EbcdicTranslator;
import haus.nightmare.lib3270j.input.InputProcessor;
import haus.nightmare.lib3270j.screen.ScreenBuffer;
import java.awt.Image;
import java.awt.Rectangle;
import java.awt.image.BufferedImage;
import haus.nightmare.lib3270j.graphics.DefaultPixelBuffer;
import haus.nightmare.lib3270j.graphics.PixelBuffer;
import haus.nightmare.lib3270j.graphics.Rectangle;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
@@ -406,11 +406,12 @@ public class Phase5EclEventTest {
}
// Test with image and rectangle
Image testImage = new BufferedImage(32, 32, BufferedImage.TYPE_INT_ARGB);
PixelBuffer testImage = new DefaultPixelBuffer(32, 32);
Rectangle rect = new Rectangle(0, 0, 32, 32);
ECLPSGraphicsEvent fullGEvent = new ECLPSGraphicsEvent(ps, ECLPSGraphicsEvent.GRAPHICS_UPDATED, testImage, rect);
assertSame(testImage, fullGEvent.GetImage());
assertSame(testImage, fullGEvent.getImage());
assertSame(testImage, fullGEvent.getPixelBuffer());
assertEquals(rect, fullGEvent.GetRectangle());
assertEquals(rect, fullGEvent.getRectangle());
@@ -0,0 +1,124 @@
package haus.nightmare.lib3270j.ecl;
import haus.nightmare.lib3270j.TerminalModel;
import haus.nightmare.lib3270j.charset.EbcdicTranslator;
import haus.nightmare.lib3270j.input.InputProcessor;
import haus.nightmare.lib3270j.screen.ScreenBuffer;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static haus.nightmare.lib3270j.protocol.DS3270Constants.*;
import static org.junit.jupiter.api.Assertions.*;
/**
* Unit tests for Phase 13.1: Excel & Tabular Clipboard Paste.
* Verifies tab-delimited text navigation, newline row advancement,
* and boundary truncation when pasteStopAtProtectedLine is set.
*/
public class TabularPasteTest {
private ScreenBuffer screen;
private EbcdicTranslator translator;
private InputProcessor input;
private ECLPS ps;
@BeforeEach
public void setUp() {
translator = new EbcdicTranslator();
screen = new ScreenBuffer(TerminalModel.IBM_3278_2, translator);
input = new InputProcessor(screen, translator, null);
ps = new ECLPS(screen, input, translator);
}
@Test
public void testExcelPasteTabsAndNewlinesAdvanceAcrossFields() {
// Setup 2 rows with 2 unprotected fields each
// Row 0: pos 0 (unprotected), pos 10 (protected), pos 11 (unprotected), pos 30 (protected)
screen.setFieldAttribute(0, (byte) FA_PRINTABLE);
screen.setFieldAttribute(10, (byte) (FA_PRINTABLE | FA_PROTECT));
screen.setFieldAttribute(11, (byte) FA_PRINTABLE);
screen.setFieldAttribute(30, (byte) (FA_PRINTABLE | FA_PROTECT));
// Row 1: pos 80 (unprotected), pos 90 (protected), pos 91 (unprotected), pos 110 (protected)
screen.setFieldAttribute(80, (byte) FA_PRINTABLE);
screen.setFieldAttribute(90, (byte) (FA_PRINTABLE | FA_PROTECT));
screen.setFieldAttribute(91, (byte) FA_PRINTABLE);
screen.setFieldAttribute(110, (byte) (FA_PRINTABLE | FA_PROTECT));
// Position cursor at first field (pos 1)
screen.setCursorAddress(1);
String tabularData = "ABC\tDEF\r\nGHI\tJKL";
int pasted = input.pasteText(tabularData, true, false);
assertEquals(12, pasted);
// Verify Field 1 (Row 0, Col 1-3)
assertEquals("ABC", ps.getString(1, 3));
// Verify Field 2 (Row 0, Col 12-14)
assertEquals("DEF", ps.getString(12, 3));
// Verify Field 3 (Row 1, Col 1-3 -> pos 81)
assertEquals("GHI", ps.getString(81, 3));
// Verify Field 4 (Row 1, Col 12-14 -> pos 92)
assertEquals("JKL", ps.getString(92, 3));
}
@Test
public void testPasteStopAtProtectedBoundaryHaltsPaste() {
// Field 1: pos 0 (unprotected), pos 5 (protected)
// Data area is pos 1..4 (4 characters)
screen.setFieldAttribute(0, (byte) FA_PRINTABLE);
screen.setFieldAttribute(5, (byte) (FA_PRINTABLE | FA_PROTECT));
screen.setFieldAttribute(10, (byte) FA_PRINTABLE);
screen.setCursorAddress(1);
// Try to paste 6 characters when only 4 fit
String overflow = "123456";
int pasted = input.pasteText(overflow, false, true);
// Should paste 4 chars and halt at the protected boundary pos 5
assertEquals(4, pasted);
assertEquals("1234", ps.getString(1, 4));
// Field at pos 10 should not be touched
assertEquals(" ", ps.getString(11, 1));
}
@Test
public void testPasteStopAtProtectedLineHaltsAtProtectedRow() {
// Row 0: unprotected field at pos 0
screen.setFieldAttribute(0, (byte) FA_PRINTABLE);
// Row 1: entirely protected starting at pos 80
screen.setFieldAttribute(80, (byte) (FA_PRINTABLE | FA_PROTECT));
screen.setCursorAddress(1);
String multiRow = "DATA1\r\nDATA2";
int pasted = input.pasteText(multiRow, true, true);
// "DATA1" (5 chars) pasted, then newline sees next line is protected and halts
assertEquals(5, pasted);
assertEquals("DATA1", ps.getString(1, 5));
}
@Test
public void testEclPsPasteFromExcelMethodsAndFlags() {
assertTrue(ps.isEnablePasteFromExcel());
assertFalse(ps.isPasteStopAtProtectedLine());
ps.setEnablePasteFromExcel(false);
assertFalse(ps.isEnablePasteFromExcel());
ps.setPasteStopAtProtectedLine(true);
assertTrue(ps.isPasteStopAtProtectedLine());
// Setup fields
screen.setFieldAttribute(0, (byte) FA_PRINTABLE);
screen.setFieldAttribute(10, (byte) (FA_PRINTABLE | FA_PROTECT));
screen.setFieldAttribute(11, (byte) FA_PRINTABLE);
screen.setFieldAttribute(30, (byte) (FA_PRINTABLE | FA_PROTECT));
ps.pasteFromExcel("HELLO\tWORLD", 0, 1);
assertEquals("HELLO", ps.getString(1, 5));
assertEquals("WORLD", ps.getString(12, 5));
}
}
@@ -0,0 +1,100 @@
package haus.nightmare.lib3270j.graphics;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.*;
/**
* Architectural compliance audit test verifying 100% decoupling of lib3270j from java.awt.
* Ensures headless execution and portability to Android (which lacks java.awt.*).
*/
public class AwtDecouplingAuditTest {
private static final Pattern IMPORT_AWT_PATTERN = Pattern.compile("^\\s*import\\s+java\\.awt\\..*;");
private static final Pattern DIRECT_AWT_USAGE_PATTERN = Pattern.compile("(?<!Class\\.forName\\(\")java\\.awt\\.[A-Za-z0-9_]+");
@Test
@DisplayName("Audit lib3270j/src/main/java for zero java.awt.* imports or references")
public void testZeroAwtDependenciesInMainSource() throws IOException {
File srcMainDir = new File("src/main/java");
if (!srcMainDir.exists()) {
srcMainDir = new File("lib3270j/src/main/java");
}
assertTrue(srcMainDir.exists() && srcMainDir.isDirectory(),
"src/main/java directory must exist at " + srcMainDir.getAbsolutePath());
List<String> violations = new ArrayList<>();
try (Stream<Path> paths = Files.walk(srcMainDir.toPath())) {
List<Path> javaFiles = paths
.filter(Files::isRegularFile)
.filter(p -> p.toString().endsWith(".java"))
.collect(Collectors.toList());
assertFalse(javaFiles.isEmpty(), "Found no java files in " + srcMainDir);
for (Path p : javaFiles) {
List<String> lines = Files.readAllLines(p);
for (int lineNum = 0; lineNum < lines.size(); lineNum++) {
String line = lines.get(lineNum);
String trimmed = line.trim();
// Ignore comment lines
if (trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*")) {
continue;
}
if (IMPORT_AWT_PATTERN.matcher(line).find()) {
violations.add(String.format("%s:%d -> %s", p.getFileName(), lineNum + 1, trimmed));
} else if (line.contains("java.awt.") && !line.contains("Class.forName(\"java.awt.") && !line.contains("\"java.awt.")) {
violations.add(String.format("%s:%d [raw type reference] -> %s", p.getFileName(), lineNum + 1, trimmed));
}
}
}
}
if (!violations.isEmpty()) {
fail("Found java.awt dependencies in lib3270j main source:\n" + String.join("\n", violations));
}
}
@Test
@DisplayName("Verify PixelBuffer and software rasterizer functions without AWT")
public void testPixelBufferPureJavaOperations() {
PixelBuffer pb = new DefaultPixelBuffer(100, 100);
assertEquals(100, pb.getWidth());
assertEquals(100, pb.getHeight());
// Fill rect
pb.fillRect(10, 10, 20, 20, 0xFFFF0000);
assertEquals(0xFFFF0000, pb.getPixel(15, 15));
assertEquals(0, pb.getPixel(5, 5));
// Draw line AA
pb.drawLineAA(0, 0, 99, 99, 0xFF00FF00, 1.0);
int diagPixel = pb.getPixel(50, 50);
assertNotEquals(0, diagPixel, "Anti-aliased diagonal line should render pixels");
// Clipping
pb.setClip(20, 20, 10, 10);
assertTrue(pb.isClipped(2, 80));
assertFalse(pb.isClipped(25, 25));
assertEquals(0, pb.getPixel(2, 80));
pb.setPixel(2, 80, 0xFF0000FF);
assertEquals(0, pb.getPixel(2, 80), "Clipped pixel must not be modified");
pb.clearClip();
assertFalse(pb.isClipped(5, 5));
}
}
@@ -1,7 +1,6 @@
package haus.nightmare.lib3270j.graphics;
import org.junit.jupiter.api.Test;
import java.awt.Point;
import static org.junit.jupiter.api.Assertions.*;
@@ -2,7 +2,6 @@ package haus.nightmare.lib3270j.graphics;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayOutputStream;
import java.awt.Point;
import static org.junit.jupiter.api.Assertions.*;
@@ -7,8 +7,6 @@ import haus.nightmare.lib3270j.screen.ScreenBuffer;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.awt.Color;
import static org.junit.jupiter.api.Assertions.*;
/**
@@ -4,14 +4,6 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.Image;
import java.awt.Point;
import java.awt.Rectangle;
import java.awt.image.BufferedImage;
import static org.junit.jupiter.api.Assertions.*;
/**
@@ -201,7 +193,7 @@ public class Phase2GocaEngineTest {
@Test
@DisplayName("Test HODWallpaper Tile, Center, and Stretch")
public void testHODWallpaper() {
BufferedImage img = new BufferedImage(32, 32, BufferedImage.TYPE_INT_ARGB);
PixelBuffer img = new DefaultPixelBuffer(32, 32);
HODWallpaper wp = new HODWallpaper(img, HODWallpaper.HOD_CENTER);
assertEquals(HODWallpaper.HOD_CENTER, wp.getDisplay());
@@ -211,10 +203,8 @@ public class Phase2GocaEngineTest {
wp.setDisplay(HODWallpaper.HOD_STRETCH);
assertEquals(HODWallpaper.HOD_STRETCH, wp.getDisplay());
BufferedImage canvas = new BufferedImage(100, 100, BufferedImage.TYPE_INT_ARGB);
Graphics g = canvas.getGraphics();
wp.paint(new java.awt.Canvas(), g, 0, 0, 100, 100);
g.dispose();
PixelBuffer canvas = new DefaultPixelBuffer(100, 100);
wp.paint(canvas, 0, 0, 100, 100);
}
@Test
@@ -283,8 +273,9 @@ public class Phase2GocaEngineTest {
assertEquals(101, bounds.width);
assertEquals(101, bounds.height);
Image img = fa.getImage();
Object img = fa.getImage();
assertNotNull(img);
assertNotNull(fa.getPixelBuffer());
fa.dispose();
}
@@ -10,7 +10,7 @@ import haus.nightmare.lib3270j.screen.ScreenBuffer;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.awt.Point;
import haus.nightmare.lib3270j.graphics.Point;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
@@ -0,0 +1,111 @@
package haus.nightmare.lib3270j.integration;
import haus.nightmare.lib3270j.ConnectionConfig;
import haus.nightmare.lib3270j.ConnectionState;
import haus.nightmare.lib3270j.Telnet3270Client;
import haus.nightmare.lib3270j.TerminalModel;
import haus.nightmare.lib3270j.listener.ConnectionListener;
import haus.nightmare.lib3270j.listener.ScreenUpdateListener;
import haus.nightmare.lib3270j.screen.ScreenBuffer;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.logging.Logger;
import static org.junit.jupiter.api.Assertions.*;
/**
* Live integration test verifying headless connection to live MVS host:
* mvs.hugfreevikings.wtf:1023 using guest credentials.
*/
@Tag("integration")
public class LiveHostHeadlessTest {
private static final Logger logger = Logger.getLogger(LiveHostHeadlessTest.class.getName());
private static final String HOST = "mvs.hugfreevikings.wtf";
private static final int PORT = 1023;
@Test
@DisplayName("Connect headless Telnet3270Client to mvs.hugfreevikings.wtf:1023")
public void testLiveMvsConnection() throws Exception {
// Probe host reachability (timeout 3 seconds)
boolean reachable = false;
try (Socket probe = new Socket()) {
probe.connect(new InetSocketAddress(HOST, PORT), 3000);
reachable = true;
} catch (IOException e) {
logger.warning("Live host " + HOST + ":" + PORT + " unreachable: " + e.getMessage());
}
Assumptions.assumeTrue(reachable, "Skipping live test: " + HOST + ":" + PORT + " is not reachable");
ConnectionConfig config = new ConnectionConfig(HOST, PORT, TerminalModel.IBM_3279_2);
config.setConnectTimeoutMs(5000);
config.setSoTimeoutMs(10000);
Telnet3270Client client = new Telnet3270Client(config);
CountDownLatch connectedLatch = new CountDownLatch(1);
CountDownLatch screenLatch = new CountDownLatch(1);
client.addConnectionListener(new ConnectionListener() {
@Override
public void onConnectionStateChanged(ConnectionState oldState, ConnectionState newState) {
if (newState == ConnectionState.CONNECTED_3270) {
connectedLatch.countDown();
}
}
@Override
public void onConnectionError(String message) {}
});
client.addScreenUpdateListener(new ScreenUpdateListener() {
@Override
public void onScreenUpdated() {
ScreenBuffer sb = client.getScreenBuffer();
if (sb != null && sb.isFormatted()) {
screenLatch.countDown();
}
}
});
try {
client.connect();
boolean connected = connectedLatch.await(5, TimeUnit.SECONDS);
assertTrue(connected, "Client must establish CONNECTED_3270 state");
boolean screenReceived = screenLatch.await(5, TimeUnit.SECONDS);
assertTrue(screenReceived, "Client must receive formatted screen from live MVS host");
ScreenBuffer sb = client.getScreenBuffer();
assertNotNull(sb);
StringBuilder fullText = new StringBuilder();
for (int i = 0; i < sb.getSize(); i++) {
char c = sb.getCell(i).ucs4;
fullText.append(c > ' ' ? c : ' ');
}
String screenContent = fullText.toString().trim();
assertFalse(screenContent.isEmpty(), "Screen buffer text should not be empty");
// Verify headless graphics plane is initialized without AWT
assertNotNull(client.getGraphicsPlane());
assertEquals(720, client.getGraphicsPlane().getWidth());
assertEquals(384, client.getGraphicsPlane().getHeight());
logger.info("Successfully connected to live host. Banner preview: " +
screenContent.substring(0, Math.min(200, screenContent.length())).replaceAll("\\s+", " "));
} finally {
client.disconnect();
}
}
}