diff --git a/j3270/src/main/java/haus/nightmare/j3270/J3270App.java b/j3270/src/main/java/haus/nightmare/j3270/J3270App.java index d718066..16ef191 100644 --- a/j3270/src/main/java/haus/nightmare/j3270/J3270App.java +++ b/j3270/src/main/java/haus/nightmare/j3270/J3270App.java @@ -1002,8 +1002,72 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate "About j3270", JOptionPane.INFORMATION_MESSAGE); } + /** + * Configure application logging. When debug is false, disables all logging + * to console and file, preventing creation of j3270.log. + */ + public static void configureLogging(boolean debug) { + Logger globalRoot = Logger.getLogger(""); + for (java.util.logging.Handler h : globalRoot.getHandlers()) { + globalRoot.removeHandler(h); + try { + h.close(); + } catch (Exception ignored) { + } + } + + if (debug) { + Level logLevel = Level.ALL; + globalRoot.setLevel(Level.ALL); + + java.util.logging.Filter appFilter = record -> record.getLoggerName() != null && + (record.getLoggerName().startsWith("haus.nightmare") || record.getLoggerName().startsWith("org.pubvm")); + + ConsoleHandler consoleHandler = new ConsoleHandler(); + consoleHandler.setLevel(Level.ALL); + consoleHandler.setFormatter(new SimpleFormatter()); + consoleHandler.setFilter(appFilter); + globalRoot.addHandler(consoleHandler); + + Logger.getLogger("haus.nightmare").setLevel(Level.ALL); + Logger.getLogger("haus.nightmare.j3270").setLevel(Level.ALL); + Logger.getLogger("haus.nightmare.lib3270j").setLevel(Level.ALL); + + try { + java.util.logging.FileHandler fileHandler = new java.util.logging.FileHandler("j3270.log", 10 * 1024 * 1024, 1, false) { + @Override + public synchronized void publish(java.util.logging.LogRecord record) { + super.publish(record); + flush(); + } + }; + fileHandler.setLevel(Level.ALL); + fileHandler.setFormatter(new SimpleFormatter()); + fileHandler.setFilter(appFilter); + globalRoot.addHandler(fileHandler); + log.info("Logging protocol trace to j3270.log (debug=" + debug + ", level=" + logLevel + ")"); + } catch (Exception e) { + System.err.println("Could not create j3270.log: " + e.getMessage()); + } + } else { + globalRoot.setLevel(Level.OFF); + for (String pkg : new String[]{"haus.nightmare", "haus.nightmare.j3270", "haus.nightmare.lib3270j", "org.pubvm"}) { + Logger l = Logger.getLogger(pkg); + l.setLevel(Level.OFF); + for (java.util.logging.Handler h : l.getHandlers()) { + l.removeHandler(h); + try { + h.close(); + } catch (Exception ignored) { + } + } + } + } + } + // ========== Main ========== + public static void main(String[] args) { boolean debug = false; boolean cliTls = false; @@ -1045,42 +1109,8 @@ public class J3270App extends JFrame implements ConnectionListener, ScreenUpdate } } - Level logLevel = debug ? Level.FINE : Level.INFO; + configureLogging(debug); - Logger globalRoot = Logger.getLogger(""); - for (java.util.logging.Handler h : globalRoot.getHandlers()) { - globalRoot.removeHandler(h); - } - - java.util.logging.Filter appFilter = record -> record.getLoggerName() != null && - (record.getLoggerName().startsWith("haus.nightmare") || record.getLoggerName().startsWith("org.pubvm")); - - ConsoleHandler consoleHandler = new ConsoleHandler(); - consoleHandler.setLevel(Level.ALL); - consoleHandler.setFormatter(new SimpleFormatter()); - consoleHandler.setFilter(appFilter); - globalRoot.addHandler(consoleHandler); - - Logger.getLogger("haus.nightmare").setLevel(Level.ALL); - Logger.getLogger("haus.nightmare.j3270").setLevel(Level.ALL); - Logger.getLogger("haus.nightmare.lib3270j").setLevel(Level.ALL); - - try { - java.util.logging.FileHandler fileHandler = new java.util.logging.FileHandler("j3270.log", 10 * 1024 * 1024, 1, false) { - @Override - public synchronized void publish(java.util.logging.LogRecord record) { - super.publish(record); - flush(); - } - }; - fileHandler.setLevel(Level.ALL); - fileHandler.setFormatter(new SimpleFormatter()); - fileHandler.setFilter(appFilter); - globalRoot.addHandler(fileHandler); - log.info("Logging protocol trace to j3270.log (debug=" + debug + ", level=" + logLevel + ")"); - } catch (Exception e) { - System.err.println("Could not create j3270.log: " + e.getMessage()); - } if (configFile != null) { try { diff --git a/j3270/src/test/java/haus/nightmare/j3270/LoggingConfigurationTest.java b/j3270/src/test/java/haus/nightmare/j3270/LoggingConfigurationTest.java new file mode 100644 index 0000000..af9f89b --- /dev/null +++ b/j3270/src/test/java/haus/nightmare/j3270/LoggingConfigurationTest.java @@ -0,0 +1,93 @@ +package haus.nightmare.j3270; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.PrintStream; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.Logger; + +import static org.junit.jupiter.api.Assertions.*; + +public class LoggingConfigurationTest { + + private PrintStream originalOut; + private PrintStream originalErr; + + @BeforeEach + public void setUp() { + originalOut = System.out; + originalErr = System.err; + } + + @AfterEach + public void tearDown() { + System.setOut(originalOut); + System.setErr(originalErr); + // Ensure all handlers are closed and logging reset + J3270App.configureLogging(false); + } + + @Test + public void testLoggingDisabledWhenDebugFalse() { + File logFile = new File("j3270.log"); + if (logFile.exists()) { + logFile.delete(); + } + + ByteArrayOutputStream outContent = new ByteArrayOutputStream(); + ByteArrayOutputStream errContent = new ByteArrayOutputStream(); + System.setOut(new PrintStream(outContent)); + System.setErr(new PrintStream(errContent)); + + J3270App.configureLogging(false); + + // Root logger should have no handlers attached + Logger rootLogger = Logger.getLogger(""); + assertEquals(0, rootLogger.getHandlers().length, "Root logger should have no handlers when debug is disabled"); + assertEquals(Level.OFF, rootLogger.getLevel(), "Root logger level should be OFF when debug is disabled"); + + // Application loggers should be OFF + assertEquals(Level.OFF, Logger.getLogger("haus.nightmare").getLevel()); + assertEquals(Level.OFF, Logger.getLogger("haus.nightmare.j3270").getLevel()); + assertEquals(Level.OFF, Logger.getLogger("haus.nightmare.lib3270j").getLevel()); + + // Emit log records at all levels + Logger appLogger = Logger.getLogger("haus.nightmare.j3270.J3270App"); + appLogger.severe("Test SEVERE message"); + appLogger.warning("Test WARNING message"); + appLogger.info("Test INFO message"); + appLogger.fine("Test FINE message"); + + // Verify nothing was written to stdout or stderr + assertEquals(0, outContent.size(), "Standard output should be empty when debug is disabled"); + assertEquals(0, errContent.size(), "Standard error should be empty when debug is disabled"); + + // Verify j3270.log was NOT created + assertFalse(logFile.exists(), "j3270.log should NOT be created when debug is disabled"); + } + + @Test + public void testLoggingEnabledWhenDebugTrue() { + J3270App.configureLogging(true); + + Logger rootLogger = Logger.getLogger(""); + Handler[] handlers = rootLogger.getHandlers(); + assertTrue(handlers.length >= 2, "Root logger should have at least ConsoleHandler and FileHandler when debug is enabled"); + + assertEquals(Level.ALL, rootLogger.getLevel()); + assertEquals(Level.ALL, Logger.getLogger("haus.nightmare").getLevel()); + assertEquals(Level.ALL, Logger.getLogger("haus.nightmare.j3270").getLevel()); + assertEquals(Level.ALL, Logger.getLogger("haus.nightmare.lib3270j").getLevel()); + + File logFile = new File("j3270.log"); + assertTrue(logFile.exists(), "j3270.log should be created when debug is enabled"); + + // Clean up + J3270App.configureLogging(false); + } +} diff --git a/lib3270j/src/main/java/haus/nightmare/lib3270j/datastream/DataStreamProcessor.java b/lib3270j/src/main/java/haus/nightmare/lib3270j/datastream/DataStreamProcessor.java index 446229d..7e82c96 100644 --- a/lib3270j/src/main/java/haus/nightmare/lib3270j/datastream/DataStreamProcessor.java +++ b/lib3270j/src/main/java/haus/nightmare/lib3270j/datastream/DataStreamProcessor.java @@ -54,6 +54,12 @@ public class DataStreamProcessor { private boolean unlockSysPending = false; private boolean rcvdRead = false; + // Modal SA (set attribute) character attributes + private byte currentFg = 0; + private byte currentBg = 0; + private byte currentGr = 0; + private byte currentCs = 0; + /** Functional interface for sending output back through the telnet stack. */ @FunctionalInterface public interface OutputSender { @@ -240,6 +246,10 @@ public class DataStreamProcessor { programSymbolManager.commitStagedSymbols(); log.info(">>> EAU: erasing all unprotected fields"); screen.eraseAllUnprotected(); + currentFg = 0; + currentBg = 0; + currentGr = 0; + currentCs = 0; break; case CMD_WSF: case SNA_CMD_WSF: @@ -378,11 +388,19 @@ public class DataStreamProcessor { if (wccReset(wcc)) { // Reset all character attributes to defaults log.fine("WCC reset: clearing default attributes"); + currentFg = 0; + currentBg = 0; + currentGr = 0; + currentCs = 0; } if (eraseFirst) { screen.clear(); log.fine("Cleared screen for Erase/Write"); + currentFg = 0; + currentBg = 0; + currentGr = 0; + currentCs = 0; } // Process orders and data starting at byte 2 @@ -390,9 +408,6 @@ public class DataStreamProcessor { int end = offset + length; int baddr = screen.getBufferAddress(); int size = screen.getRows() * screen.getCols(); - - // Current SA (set attribute) values for character-mode - byte currentFg = 0, currentBg = 0, currentGr = 0, currentCs = 0; boolean lastWasOrder = false; while (pos < end) { @@ -427,10 +442,6 @@ public class DataStreamProcessor { // FA position is a display position that shows as blank ea.ec = 0; ea.ucs4 = ' '; - currentFg = 0; - currentBg = 0; - currentGr = 0; - currentCs = 0; screen.setFormatted(true); baddr = (baddr + 1) % size; screen.setBufferAddress(baddr); @@ -455,10 +466,6 @@ public class DataStreamProcessor { ea.clear(); ea.ec = 0; ea.ucs4 = ' '; - currentFg = 0; - currentBg = 0; - currentGr = 0; - currentCs = 0; for (int i = 0; i < nPairs; i++) { int attrType = data[pos + 2 + i * 2] & 0xFF; @@ -485,6 +492,12 @@ public class DataStreamProcessor { int attrType = data[pos + 1] & 0xFF; int attrValue = data[pos + 2] & 0xFF; switch (attrType) { + case XA_ALL: + currentFg = 0; + currentBg = 0; + currentGr = 0; + currentCs = 0; + break; case XA_FOREGROUND: currentFg = (byte) attrValue; break; diff --git a/lib3270j/src/test/java/haus/nightmare/lib3270j/datastream/DataStreamProcessorTest.java b/lib3270j/src/test/java/haus/nightmare/lib3270j/datastream/DataStreamProcessorTest.java index d1871b9..7482ccb 100644 --- a/lib3270j/src/test/java/haus/nightmare/lib3270j/datastream/DataStreamProcessorTest.java +++ b/lib3270j/src/test/java/haus/nightmare/lib3270j/datastream/DataStreamProcessorTest.java @@ -82,4 +82,120 @@ public class DataStreamProcessorTest { assertNotNull(sentData.get()); assertEquals((byte) AID_ENTER, sentData.get()[0], "ReadBuffer must transmit operator's stored AID"); } + + @Test + public void testCharacterAttributesPersistAcrossStartField() throws java.io.IOException { + // User's exact ISPF Option 0 sequence: + // SBA(7, 1) -> SF(prot,skip) -> SA(yellow) -> ' 4 ' -> SBA(7, 7) -> SF(prot,skip) -> 'DISPLAY' + java.io.ByteArrayOutputStream stream = new java.io.ByteArrayOutputStream(); + stream.write(CMD_EW); + stream.write(0xC3); // WCC + + // SBA(7, 1) -> address 7*80 + 1 = 561 + byte[] addr1 = encodeAddress(561, 24, 80); + stream.write(ORDER_SBA); + stream.write(addr1); + + // SF(prot, skip) + stream.write(ORDER_SF); + stream.write(FA_PRINTABLE | FA_PROTECT | FA_NUMERIC); + + // SA(yellow) -> XA_FOREGROUND, COLOR_YELLOW (0xF6) + stream.write(ORDER_SA); + stream.write(XA_FOREGROUND); + stream.write(0xF6); + + // Data: ' 4 ' + stream.write(translator.stringToEbcdic(" 4 ")); + + // SBA(7, 7) -> address 7*80 + 7 = 567 + byte[] addr2 = encodeAddress(567, 24, 80); + stream.write(ORDER_SBA); + stream.write(addr2); + + // SF(prot, skip) + stream.write(ORDER_SF); + stream.write(FA_PRINTABLE | FA_PROTECT | FA_NUMERIC); + + // Data: 'DISPLAY' + stream.write(translator.stringToEbcdic("DISPLAY")); + + byte[] record = stream.toByteArray(); + processor.processRecord(record, 0, record.length, true); + + // Positions 562..566 (' 4 ') must be yellow (0xF6) + for (int i = 562; i <= 566; i++) { + assertEquals((byte) 0xF6, screen.getCell(i).fg, "Cell at " + i + " should have yellow foreground (0xF6)"); + } + + // Positions 568..574 ('DISPLAY') across the second SF must also retain yellow (0xF6) + for (int i = 568; i <= 574; i++) { + assertEquals((byte) 0xF6, screen.getCell(i).fg, "Cell at " + i + " ('DISPLAY') should retain yellow foreground (0xF6)"); + } + + // Now test that SA with XA_ALL resets character attributes to default (0) + java.io.ByteArrayOutputStream resetStream = new java.io.ByteArrayOutputStream(); + resetStream.write(CMD_W); + resetStream.write(0xC3); + resetStream.write(ORDER_SA); + resetStream.write(XA_ALL); + resetStream.write(0x00); + resetStream.write(translator.stringToEbcdic("TEST")); + byte[] resetRecord = resetStream.toByteArray(); + processor.processRecord(resetRecord, 0, resetRecord.length, true); + + for (int i = 575; i < 579; i++) { + assertEquals((byte) 0, screen.getCell(i).fg, "Cell at " + i + " should have default foreground (0) after SA(XA_ALL)"); + } + } + + @Test + public void testCharacterAttributesPersistAcrossStartFieldExtended() throws java.io.IOException { + java.io.ByteArrayOutputStream stream = new java.io.ByteArrayOutputStream(); + stream.write(CMD_EW); + stream.write(0xC3); + + // SA(yellow) + stream.write(ORDER_SA); + stream.write(XA_FOREGROUND); + stream.write(0xF6); + + // SFE with 1 pair (3270 FA) + stream.write(ORDER_SFE); + stream.write(0x01); // 1 pair + stream.write(XA_3270); + stream.write(FA_PRINTABLE); + + // Data 'HELLO' + stream.write(translator.stringToEbcdic("HELLO")); + + byte[] record = stream.toByteArray(); + processor.processRecord(record, 0, record.length, true); + + // Check cells of HELLO (positions 1..5) have fg == 0xF6 + for (int i = 1; i <= 5; i++) { + assertEquals((byte) 0xF6, screen.getCell(i).fg, "Cell at " + i + " should retain yellow foreground across SFE"); + } + } + + @Test + public void testCharacterAttributesResetOnErase() throws java.io.IOException { + java.io.ByteArrayOutputStream stream = new java.io.ByteArrayOutputStream(); + stream.write(CMD_EW); + stream.write(0xC3); + stream.write(ORDER_SA); + stream.write(XA_FOREGROUND); + stream.write(0xF6); + stream.write(translator.stringToEbcdic("A")); + processor.processRecord(stream.toByteArray(), 0, stream.size(), true); + assertEquals((byte) 0xF6, screen.getCell(0).fg); + + // New EW command resets attributes + java.io.ByteArrayOutputStream ewStream = new java.io.ByteArrayOutputStream(); + ewStream.write(CMD_EW); + ewStream.write(0xC3); + ewStream.write(translator.stringToEbcdic("B")); + processor.processRecord(ewStream.toByteArray(), 0, ewStream.size(), true); + assertEquals((byte) 0, screen.getCell(0).fg); + } }