Hotspot detection and screen buffer
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) Successful in 1m20s
Build and Test j3270 / Build JAR & Run Tests (Java 21) (push) Successful in 1m28s
Release j3270 / Build & Publish Release (push) Successful in 1m39s

This commit is contained in:
2026-09-18 18:18:53 +00:00
parent 8a30efe9c0
commit aa724fbcef
20 changed files with 3603 additions and 34 deletions
@@ -0,0 +1,140 @@
package haus.nightmare.lib3270j.input;
import org.junit.jupiter.api.Test;
import java.io.File;
import java.io.FileReader;
import java.io.StringReader;
import java.io.StringWriter;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
public class HodKeyMapTest {
@Test
public void testModifierPrefixes() {
assertEquals('B', HodKeyMap.getModifierPrefix(0));
assertEquals('S', HodKeyMap.getModifierPrefix(HodKeyMap.MOD_SHIFT));
assertEquals('C', HodKeyMap.getModifierPrefix(HodKeyMap.MOD_CTRL));
assertEquals('D', HodKeyMap.getModifierPrefix(HodKeyMap.MOD_SHIFT | HodKeyMap.MOD_CTRL));
assertEquals('A', HodKeyMap.getModifierPrefix(HodKeyMap.MOD_ALT));
assertEquals('H', HodKeyMap.getModifierPrefix(HodKeyMap.MOD_SHIFT | HodKeyMap.MOD_ALT));
assertEquals('I', HodKeyMap.getModifierPrefix(HodKeyMap.MOD_CTRL | HodKeyMap.MOD_ALT));
assertEquals('J', HodKeyMap.getModifierPrefix(HodKeyMap.MOD_SHIFT | HodKeyMap.MOD_CTRL | HodKeyMap.MOD_ALT));
assertEquals(0, HodKeyMap.getModifiersFromPrefix('B'));
assertEquals(HodKeyMap.MOD_SHIFT, HodKeyMap.getModifiersFromPrefix('S'));
assertEquals(HodKeyMap.MOD_CTRL, HodKeyMap.getModifiersFromPrefix('C'));
assertEquals(HodKeyMap.MOD_SHIFT | HodKeyMap.MOD_CTRL, HodKeyMap.getModifiersFromPrefix('D'));
assertEquals(HodKeyMap.MOD_ALT, HodKeyMap.getModifiersFromPrefix('A'));
assertEquals(HodKeyMap.MOD_SHIFT | HodKeyMap.MOD_ALT, HodKeyMap.getModifiersFromPrefix('H'));
assertEquals(HodKeyMap.MOD_CTRL | HodKeyMap.MOD_ALT, HodKeyMap.getModifiersFromPrefix('I'));
assertEquals(HodKeyMap.MOD_SHIFT | HodKeyMap.MOD_CTRL | HodKeyMap.MOD_ALT, HodKeyMap.getModifiersFromPrefix('J'));
}
@Test
public void testHashKeyParsingStandardKeys() {
// A67 = Alt + C
HodKeyMap.KeyEntry ke1 = HodKeyMap.parseHashKey("A67", "[clear]");
assertNotNull(ke1);
assertEquals(67, ke1.getKeyCode());
assertEquals(HodKeyMap.MOD_ALT, ke1.getModifiers());
assertEquals(HodKeyMap.LOCATION_STANDARD, ke1.getKeyLocation());
assertEquals("alt C", ke1.toKeyStrokeString());
assertEquals("[clear]", ke1.getValue());
// B35 = End
HodKeyMap.KeyEntry ke2 = HodKeyMap.parseHashKey("B35", "[eraseeof]");
assertNotNull(ke2);
assertEquals(35, ke2.getKeyCode());
assertEquals(0, ke2.getModifiers());
assertEquals("END", ke2.toKeyStrokeString());
// A82 = Alt + R
HodKeyMap.KeyEntry ke3 = HodKeyMap.parseHashKey("A82", "[reset]");
assertNotNull(ke3);
assertEquals(82, ke3.getKeyCode());
assertEquals(HodKeyMap.MOD_ALT, ke3.getModifiers());
assertEquals("alt R", ke3.toKeyStrokeString());
}
@Test
public void testDuplicateAndNumpadKeys() {
// Numpad Down: 225
HodKeyMap.KeyEntry keDown = HodKeyMap.parseHashKey("B225", "[down]");
assertNotNull(keDown);
assertEquals(HodKeyMap.VK_DOWN, keDown.getKeyCode());
assertEquals(HodKeyMap.LOCATION_NUMPAD, keDown.getKeyLocation());
assertEquals("DOWN", keDown.toKeyStrokeString());
// Numpad Enter: 0x2000000 + 10 = 33554442
HodKeyMap.KeyEntry keEnter = HodKeyMap.parseHashKey("B33554442", "[enter]");
assertNotNull(keEnter);
assertEquals(HodKeyMap.VK_ENTER, keEnter.getKeyCode());
assertEquals(HodKeyMap.LOCATION_NUMPAD, keEnter.getKeyLocation());
assertEquals("ENTER", keEnter.toKeyStrokeString());
// Generation of hash keys
assertEquals("B224", HodKeyMap.hashKey(HodKeyMap.VK_UP, 0, HodKeyMap.LOCATION_NUMPAD));
assertEquals("B225", HodKeyMap.hashKey(HodKeyMap.VK_DOWN, 0, HodKeyMap.LOCATION_NUMPAD));
assertEquals("B226", HodKeyMap.hashKey(HodKeyMap.VK_LEFT, 0, HodKeyMap.LOCATION_NUMPAD));
assertEquals("B227", HodKeyMap.hashKey(HodKeyMap.VK_RIGHT, 0, HodKeyMap.LOCATION_NUMPAD));
assertEquals("B33554442", HodKeyMap.hashKey(HodKeyMap.VK_ENTER, 0, HodKeyMap.LOCATION_NUMPAD));
}
@Test
public void testParseHodKmpString() throws Exception {
String kmpContent =
"[KeyRemap]\n" +
"XF:[textlogicaldisp]=[textlogicaldisp]\n" +
"A67=[clear]\n" +
"autoApply=false\n" +
"keyVersion=2\n" +
"B35=[eraseeof]\n" +
"sessionType=1\n" +
"disableKeyBuffer=false\n" +
"A82=[reset]\n" +
"codePage=037\n";
Map<String, String> map = HodKeyMap.parseHodKmp(new StringReader(kmpContent));
assertEquals("alt C", map.get("CLEAR"));
assertEquals("END", map.get("END"));
assertEquals("alt R", map.get("ESCAPE"));
}
@Test
public void testParseStandardKmpFileIfPresent() throws Exception {
File f = new File("/home/rudi/Documents/IBM HoD/Standard.kmp");
if (f.exists()) {
try (FileReader fr = new FileReader(f)) {
Map<String, String> map = HodKeyMap.parseHodKmp(fr);
assertEquals("alt C", map.get("CLEAR"));
assertEquals("END", map.get("END"));
assertEquals("alt R", map.get("ESCAPE"));
}
}
}
@Test
public void testExportHodKmp() throws Exception {
Map<String, String> bindings = new LinkedHashMap<>();
bindings.put("CLEAR", "alt C");
bindings.put("END", "END");
bindings.put("ESCAPE", "alt R");
StringWriter sw = new StringWriter();
HodKeyMap.exportHodKmp(sw, bindings);
String out = sw.toString();
assertTrue(out.contains("[KeyRemap]"));
assertTrue(out.contains("A67=[clear]"));
assertTrue(out.contains("B35=[eraseeof]"));
assertTrue(out.contains("A82=[reset]"));
// Verify roundtrip
Map<String, String> parsed = HodKeyMap.parseHodKmp(new StringReader(out));
assertEquals("alt C", parsed.get("CLEAR"));
assertEquals("END", parsed.get("END"));
assertEquals("alt R", parsed.get("ESCAPE"));
}
}
@@ -0,0 +1,88 @@
package haus.nightmare.lib3270j.input;
import haus.nightmare.lib3270j.TerminalModel;
import haus.nightmare.lib3270j.charset.EbcdicTranslator;
import haus.nightmare.lib3270j.ecl.ECLPS;
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.*;
public class PasteWordBreakTest {
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);
screen.erase(false);
// Field 1: pos 0 (FA), pos 1..5 unprotected (length 5)
// pos 6: FA (unprotected), pos 7..16 unprotected (length 10)
// pos 17: FA (protected)
screen.setFieldAttribute(0, (byte) FA_PRINTABLE);
screen.setFieldAttribute(6, (byte) FA_PRINTABLE);
screen.setFieldAttribute(17, (byte) (FA_PRINTABLE | FA_PROTECT));
}
@Test
public void testPasteWordBreakPreventsSplittingAcrossFields() {
// Cursor at pos 1 (start of Field 1)
screen.setCursorAddress(1);
input.typeCharacter('A');
input.typeCharacter('B');
input.typeCharacter('C');
// Now cursor is at pos 4, remaining space in Field 1 is 2 (pos 4 and 5)
// Paste "WORLD": length 5, does not fit in 2 positions
// With pasteWordBreak=true, it should skip to Field 2 (pos 7) and paste WORLD
input.setPasteWordBreak(true);
input.pasteText("WORLD", false, false, false, true);
// Field 1: pos 1='A', 2='B', 3='C' with trailing blanks untouched
assertEquals("ABC ", ps.getString(1, 5));
// Field 2: pos 7..11 should have "WORLD"
assertEquals("WORLD", ps.getString(7, 5));
}
@Test
public void testPasteWithoutWordBreakSplitsWord() {
// Cursor at pos 1 (start of Field 1)
screen.setCursorAddress(1);
input.typeCharacter('A');
input.typeCharacter('B');
input.typeCharacter('C');
// Now cursor is at pos 4, remaining space in Field 1 is 2 (pos 4 and 5)
// With pasteWordBreak=false, "WO" goes to pos 4..5, and "RLD" spills into Field 2
input.setPasteWordBreak(false);
input.pasteText("WORLD", false, false, false, false);
assertEquals("ABCWO", ps.getString(1, 5));
// Field 2 receives "RLD"
assertEquals("RLD", ps.getString(7, 3));
}
@Test
public void testPasteWordBreakHandlesSpaceDelimiterWithoutLeadingSpaceInNextField() {
screen.setCursorAddress(1);
// Field 1 has length 5. We paste "HELLO WORLD".
// "HELLO" takes pos 1..5. The space is a delimiter.
// "WORLD" should start at pos 7 with no leading space!
input.setPasteWordBreak(true);
input.pasteText("HELLO WORLD", false, false, false, true);
assertEquals("HELLO", ps.getString(1, 5));
assertEquals("WORLD", ps.getString(7, 5));
}
}
@@ -0,0 +1,194 @@
package haus.nightmare.lib3270j.screen;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.*;
/**
* Unit tests for ScreenHistoryBuffer (Phase 25.1).
* Tests rolling 64-frame ring buffer, deduplication, chronological ordering,
* text/table extraction, and listener callbacks.
*/
public class ScreenHistoryBufferTest {
private ScreenBuffer screenBuffer;
private ScreenHistoryBuffer historyBuffer;
@BeforeEach
public void setUp() {
screenBuffer = new ScreenBuffer();
historyBuffer = new ScreenHistoryBuffer(64);
}
private void writeString(int row, int col, String text) {
for (int i = 0; i < text.length(); i++) {
screenBuffer.setChar(row, col + i, text.charAt(i));
}
}
@Test
@DisplayName("Initial buffer state is empty")
public void testInitialState() {
assertEquals(0, historyBuffer.size());
assertTrue(historyBuffer.isEmpty());
assertEquals(64, historyBuffer.getCapacity());
assertNull(historyBuffer.getLatest());
}
@Test
@DisplayName("Captures snapshot with correct dimensions, text, and cursor")
public void testSnapshotCapture() {
writeString(1, 0, "HELLO");
screenBuffer.setCursorAddress(85);
ScreenHistoryBuffer.ScreenSnapshot snap = historyBuffer.snapshot(screenBuffer);
assertNotNull(snap);
assertEquals(1, historyBuffer.size());
assertFalse(historyBuffer.isEmpty());
assertEquals(24, snap.getRows());
assertEquals(80, snap.getCols());
assertEquals(85, snap.getCursorAddress());
assertEquals(1, snap.getCursorRow());
assertEquals(5, snap.getCursorCol());
assertEquals('H', snap.getChar(1, 0));
assertEquals('E', snap.getChar(1, 1));
assertEquals('L', snap.getChar(1, 2));
assertEquals('L', snap.getChar(1, 3));
assertEquals('O', snap.getChar(1, 4));
assertEquals("HELLO", snap.getLine(1).trim());
}
@Test
@DisplayName("Deduplicates identical consecutive snapshots")
public void testDeduplication() {
screenBuffer.setChar(0, 0, 'A');
ScreenHistoryBuffer.ScreenSnapshot s1 = historyBuffer.snapshot(screenBuffer);
assertNotNull(s1);
assertEquals(1, historyBuffer.size());
// Snapshot identical buffer state without changes
ScreenHistoryBuffer.ScreenSnapshot s2 = historyBuffer.snapshot(screenBuffer);
assertNull(s2, "Consecutive identical snapshot should be suppressed");
assertEquals(1, historyBuffer.size());
// Now modify screen buffer
screenBuffer.setChar(0, 1, 'B');
ScreenHistoryBuffer.ScreenSnapshot s3 = historyBuffer.snapshot(screenBuffer);
assertNotNull(s3, "Snapshot should be captured after buffer modification");
assertEquals(2, historyBuffer.size());
}
@Test
@DisplayName("Rolls over correctly when capacity of 64 frames is exceeded")
public void testRollingRingBufferOverflow() {
// Capture 70 distinct screens
for (int i = 0; i < 70; i++) {
screenBuffer.clear();
screenBuffer.setCursorAddress(0);
String str = "SCREEN_" + String.format("%03d", i);
writeString(0, 0, str);
ScreenHistoryBuffer.ScreenSnapshot snap = historyBuffer.snapshot(screenBuffer);
assertNotNull(snap);
}
// Buffer size must be capped at 64
assertEquals(64, historyBuffer.size());
// The oldest snapshot (index 0) should be screen 6 (70 - 64 = 6)
ScreenHistoryBuffer.ScreenSnapshot oldest = historyBuffer.get(0);
assertTrue(oldest.getText().startsWith("SCREEN_006"),
"Oldest frame should be SCREEN_006, was: " + oldest.getText().substring(0, 10));
// The newest snapshot (index 63) should be screen 69
ScreenHistoryBuffer.ScreenSnapshot newest = historyBuffer.get(63);
assertTrue(newest.getText().startsWith("SCREEN_069"),
"Newest frame should be SCREEN_069, was: " + newest.getText().substring(0, 10));
// getLatest() must match newest
assertEquals(newest, historyBuffer.getLatest());
// Check full sequential list
List<ScreenHistoryBuffer.ScreenSnapshot> all = historyBuffer.getSnapshots();
assertEquals(64, all.size());
for (int i = 0; i < 64; i++) {
String expected = "SCREEN_" + String.format("%03d", i + 6);
assertTrue(all.get(i).getText().startsWith(expected));
}
}
@Test
@DisplayName("Formatted table trims trailing whitespace per line")
public void testFormattedTable() {
screenBuffer.setCursorAddress(0);
writeString(0, 0, "ABC");
ScreenHistoryBuffer.ScreenSnapshot snap = historyBuffer.snapshot(screenBuffer);
assertNotNull(snap);
String table = snap.getFormattedTable();
String[] lines = table.split("\n", -1);
assertEquals(24, lines.length);
assertEquals("ABC", lines[0]);
assertEquals("", lines[1]);
}
@Test
@DisplayName("Bounded text range returns exact sub-box")
public void testTextRange() {
screenBuffer.setCursorAddress(0);
writeString(0, 0, "ROW0");
writeString(1, 0, "ROW1");
ScreenHistoryBuffer.ScreenSnapshot snap = historyBuffer.snapshot(screenBuffer);
assertNotNull(snap);
String sub = snap.getTextRange(0, 0, 1, 3);
assertEquals("ROW0\nROW1", sub);
}
@Test
@DisplayName("ScreenHistoryListener receives callbacks on new snapshots")
public void testListenerNotification() {
AtomicInteger callCount = new AtomicInteger(0);
ScreenHistoryBuffer.ScreenHistoryListener listener = (buf, snap) -> callCount.incrementAndGet();
historyBuffer.addListener(listener);
screenBuffer.setChar(0, 0, 'X');
historyBuffer.snapshot(screenBuffer);
assertEquals(1, callCount.get());
// Duplicate snapshot doesn't fire listener
historyBuffer.snapshot(screenBuffer);
assertEquals(1, callCount.get());
// New change fires listener
screenBuffer.setChar(0, 1, 'Y');
historyBuffer.snapshot(screenBuffer);
assertEquals(2, callCount.get());
historyBuffer.removeListener(listener);
screenBuffer.setChar(0, 2, 'Z');
historyBuffer.snapshot(screenBuffer);
assertEquals(2, callCount.get(), "Removed listener should not receive callbacks");
}
@Test
@DisplayName("ScreenBuffer integration automatically records history on notifyScreenUpdate")
public void testScreenBufferAutomaticSnapshot() {
ScreenHistoryBuffer autoHist = screenBuffer.getHistoryBuffer();
assertNotNull(autoHist);
int initialSize = autoHist.size();
screenBuffer.setChar(0, 0, 'T');
screenBuffer.notifyScreenUpdate();
assertEquals(initialSize + 1, autoHist.size());
assertTrue(autoHist.getLatest().getText().startsWith("T"));
}
}