Hotspot detection debugging
Build and Test j3270 / Build JAR & Run Tests (Java 11) (push) Successful in 1m29s
Build and Test j3270 / Build JAR & Run Tests (Java 21) (push) Successful in 1m46s
Build and Test j3270 / Build JAR & Run Tests (Java 17) (push) Successful in 2m8s
Release j3270 / Build & Publish Release (push) Successful in 1m39s

This commit is contained in:
2026-10-02 13:54:28 +00:00
parent dde5918da8
commit d8acd713af
5 changed files with 216 additions and 2 deletions
@@ -129,7 +129,7 @@ public class HotspotEngine {
Pattern.CASE_INSENSITIVE
);
// 3. Menu choices: "1.", "2.", "===> 3", "=> 4", "--> 5"
// 3. Menu choices: "1.", "2.", "===> 3", "=> 4", "--> 5", ISPF (" 0 Settings", " 1 View", "10 SCLM", " X Exit")
private static final Pattern MENU_NUMBER_PATTERN = Pattern.compile(
"(?:(?<=\\s)|^)(\\d{1,2})\\.(?:(?=\\s)|$)"
);
@@ -138,6 +138,10 @@ public class HotspotEngine {
"(?:===>|=>|-->)\\s*(\\d{1,2})(?:(?=\\s)|$)"
);
private static final Pattern ISPF_MENU_PATTERN = Pattern.compile(
"(?:^\\s*|(?<=\\s{3,}))(?:(\\d{1,2}(?:\\.\\d{1,2})?)(?:\\s*[-–—]\\s*|\\s+)|([A-Za-z])(?:\\s*[-–—]\\s*|\\s{2,}))([A-Za-z][A-Za-z0-9/_-]*)"
);
// Caching state to avoid rescanning unchanged screens
private String cachedScreenText = "";
private int cachedRows = 0;
@@ -369,6 +373,28 @@ public class HotspotEngine {
hotspots.add(h);
markOccupied(occupied, startCol, endCol);
}
// Pattern 3c: ISPF menu choices (e.g. " 1 View", " 2 Edit", "10 SCLM", " X Exit", " M More", " 1 - View", " 3.1 Library")
Matcher mIspf = ISPF_MENU_PATTERN.matcher(line);
while (mIspf.find()) {
String choiceStr = mIspf.group(1) != null ? mIspf.group(1) : mIspf.group(2).toUpperCase();
String labelStr = mIspf.group(3);
int startCol = mIspf.group(1) != null ? mIspf.start(1) : mIspf.start(2);
int endCol = mIspf.end(3);
if (isRangeOccupied(occupied, startCol, endCol)) continue;
String action = choiceStr + "[enter]";
String tooltip = "Select option " + choiceStr + " (" + labelStr + ")";
String display = line.substring(startCol, endCol);
Hotspot h = new Hotspot(
HotspotType.MENU_CHOICE,
r, startCol, r, endCol - 1,
display, action, tooltip
);
hotspots.add(h);
markOccupied(occupied, startCol, endCol);
}
}
}
@@ -533,7 +533,7 @@ public class SettingsDialog extends JDialog {
gbcH.gridy = 2;
hotGroup.add(hotspotsPfCheck, gbcH);
hotspotsMenuCheck = new JCheckBox("Auto-detect Menu choices (\"1.\", \"2.\", \"===> 3\")", Settings.getHotspotsMenuChoicesEnabled());
hotspotsMenuCheck = new JCheckBox("Auto-detect Menu choices (ISPF, \"1.\", \"===> 3\")", Settings.getHotspotsMenuChoicesEnabled());
ThemeManager.styleCheckBox(hotspotsMenuCheck);
gbcH.gridy = 3;
hotGroup.add(hotspotsMenuCheck, gbcH);
@@ -533,6 +533,18 @@ public class TerminalPanel extends JPanel implements java.awt.print.Printable, I
if (h.getType() == HotspotEngine.HotspotType.URL) {
openUrl(h.getAction());
return;
} else if (h.getType() == HotspotEngine.HotspotType.MENU_CHOICE) {
// In 3270 / ISPF, clicking on a menu choice hotspot positions the cursor
// on a protected screen label. Typing directly into a protected cell triggers
// INHIBIT_PROTECTED_FIELD keyboard lock. Menu choices must be submitted via
// the command/option line (first unprotected field reachable via [home]).
String action = h.getAction();
if (!action.startsWith("[home]")) {
action = "[home][eraseeof]" + action;
}
client.getPS().SendKeys(action);
refreshScreen();
return;
} else {
client.getPS().SendKeys(h.getAction());
refreshScreen();
@@ -189,4 +189,106 @@ public class HotspotEngineTest {
List<HotspotEngine.Hotspot> empty = engine.scanText(screen, 1, 80);
assertEquals(0, empty.size());
}
@Test
@DisplayName("Detects ISPF primary option menu choices (numbers, two-digit, and letters)")
public void testIspfPrimaryOptionMenuDetection() {
String screen =
" Menu Utilities Compilers Options Status Help\n" +
" ──────────────────────────────────────────────────────────────────────────────\n" +
" ISPF Primary Option Menu\n" +
" Option ===>\n" +
" \n" +
" 0 Settings Terminal and user parameters User ID . : RUDI\n" +
" 1 View Display source data or listings Time. . . : 12:31\n" +
" 2 Edit Create or change source data Terminal. : 3278\n" +
" 3 Utilities Perform utility functions Screen. . : 1\n" +
" 4 Foreground Interactive language processing Language. : ENGLISH\n" +
" 5 Batch Submit job for language processing Appl ID . : ISR\n" +
" 6 Command Enter TSO or Workstation commands TSO logon : DEVPROC\n" +
" 7 Dialog Test Perform dialog testing TSO prefix: RUDI\n" +
" 9 IBM Products IBM program development products System ID : VS01\n" +
" 10 SCLM SW Configuration Library Manager Release . : ISPF 7.5\n" +
" 11 Workplace ISPF Object/Action Workplace\n" +
" M More Additional workplace options\n" +
" X Exit Terminate ISPF using log and list defaults";
List<HotspotEngine.Hotspot> hotspots = engine.scanText(screen, 18, 80);
// 14 total hotspots: 13 ISPF menu choices (0, 1, 2, 3, 4, 5, 6, 7, 9, 10, 11, M, X) + 1 [ENTER] key on row 11
assertEquals(14, hotspots.size());
List<HotspotEngine.Hotspot> menuChoices = hotspots.stream()
.filter(h -> h.getType() == HotspotEngine.HotspotType.MENU_CHOICE)
.collect(java.util.stream.Collectors.toList());
assertEquals(13, menuChoices.size());
// Option 0
HotspotEngine.Hotspot h0 = menuChoices.get(0);
assertEquals(HotspotEngine.HotspotType.MENU_CHOICE, h0.getType());
assertEquals("0 Settings", h0.getDisplayText());
assertEquals("0[enter]", h0.getAction());
assertEquals("Select option 0 (Settings)", h0.getTooltip());
assertEquals(5, h0.getStartRow());
// Option 1
HotspotEngine.Hotspot h1 = menuChoices.get(1);
assertEquals("1 View", h1.getDisplayText());
assertEquals("1[enter]", h1.getAction());
assertEquals("Select option 1 (View)", h1.getTooltip());
assertEquals(6, h1.getStartRow());
// Option 2
HotspotEngine.Hotspot h2 = menuChoices.get(2);
assertEquals("2 Edit", h2.getDisplayText());
assertEquals("2[enter]", h2.getAction());
assertEquals("Select option 2 (Edit)", h2.getTooltip());
// Option 10 (two-digit, index 9 after 0,1,2,3,4,5,6,7,9)
HotspotEngine.Hotspot h10 = menuChoices.get(9);
assertEquals("10 SCLM", h10.getDisplayText());
assertEquals("10[enter]", h10.getAction());
assertEquals("Select option 10 (SCLM)", h10.getTooltip());
// Option M (letter, index 11)
HotspotEngine.Hotspot hM = menuChoices.get(11);
assertEquals("M More", hM.getDisplayText());
assertEquals("M[enter]", hM.getAction());
assertEquals("Select option M (More)", hM.getTooltip());
// Option X (exit, index 12)
HotspotEngine.Hotspot hX = menuChoices.get(12);
assertEquals("X Exit", hX.getDisplayText());
assertEquals("X[enter]", hX.getAction());
assertEquals("Select option X (Exit)", hX.getTooltip());
}
@Test
@DisplayName("Detects ISPF dotted sub-options, dashes, and multi-column menus")
public void testIspfSubOptionsAndMultiColumn() {
String screen =
" 3.1 Library - Compress or print data set\n" +
" 3.2 DataSet - Allocate, rename, or delete\n" +
" 1 - View 2 - Edit";
List<HotspotEngine.Hotspot> hotspots = engine.scanText(screen, 3, 80);
assertEquals(4, hotspots.size());
HotspotEngine.Hotspot h31 = hotspots.get(0);
assertEquals("3.1 Library", h31.getDisplayText());
assertEquals("3.1[enter]", h31.getAction());
assertEquals("Select option 3.1 (Library)", h31.getTooltip());
HotspotEngine.Hotspot h32 = hotspots.get(1);
assertEquals("3.2 DataSet", h32.getDisplayText());
assertEquals("3.2[enter]", h32.getAction());
HotspotEngine.Hotspot h1 = hotspots.get(2);
assertEquals("1 - View", h1.getDisplayText());
assertEquals("1[enter]", h1.getAction());
HotspotEngine.Hotspot h2 = hotspots.get(3);
assertEquals("2 - Edit", h2.getDisplayText());
assertEquals("2[enter]", h2.getAction());
}
}
@@ -258,4 +258,78 @@ public class TerminalPanelSelectionTest {
} catch (HeadlessException ignored) {
}
}
@Test
@DisplayName("Clicking an ISPF menu choice navigates to home/option line, types choice, and sends AID_ENTER")
public void testClickOnIspfMenuChoiceSubmitsViaHomeAndEnter() {
try {
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);
// Row 3 (col 0): Protected label "Option ===> "
int row3Start = 3 * 80;
sb.setCellFA(row3Start, (byte) (FA_PRINTABLE | FA_PROTECT));
String promptText = "Option ===> ";
for (int i = 0; i < promptText.length(); i++) {
sb.getCell(row3Start + 1 + i).ucs4 = promptText.charAt(i);
sb.getCell(row3Start + 1 + i).ec = (byte) (0xC1 + i);
}
// Unprotected command input field after "Option ===> " at address 253 (row 3 col 13)
int inputFieldAddr = row3Start + 13;
sb.setCellFA(inputFieldAddr, (byte) FA_PRINTABLE); // Unprotected!
for (int i = 0; i < 20; i++) {
sb.getCell(inputFieldAddr + 1 + i).ucs4 = ' ';
sb.getCell(inputFieldAddr + 1 + i).ec = (byte) 0x40;
}
// Row 6 (address 480): Protected field with ISPF menu option " 1 View"
int row6Start = 6 * 80;
sb.setCellFA(row6Start, (byte) (FA_PRINTABLE | FA_PROTECT));
String menuText = " 1 View Display source data or listings";
for (int i = 0; i < menuText.length(); i++) {
sb.getCell(row6Start + 1 + i).ucs4 = menuText.charAt(i);
sb.getCell(row6Start + 1 + i).ec = (byte) (menuText.charAt(i) == ' ' ? 0x40 : 0xC1 + i);
}
sb.updateFormatted();
sb.updateDisplaySnapshot();
panel.setClient(client);
panel.setHotspotsDirty(true);
int cellW = panel.cellWidth > 0 ? panel.cellWidth : 10;
int cellH = panel.cellHeight > 0 ? panel.cellHeight : 18;
int ox = panel.getRenderOffsetX();
int oy = panel.getRenderOffsetY();
MouseListener ml = panel.getMouseListeners()[0];
// Click at row 6, col 2 (on the "1" in " 1 View")
int clickX = ox + 2 * cellW + cellW / 2;
int clickY = oy + 6 * cellH + cellH / 2;
ml.mousePressed(new MouseEvent(panel, MouseEvent.MOUSE_PRESSED, System.currentTimeMillis(),
0, clickX, clickY, 1, false, MouseEvent.BUTTON1));
ml.mouseReleased(new MouseEvent(panel, MouseEvent.MOUSE_RELEASED, System.currentTimeMillis(),
0, clickX, clickY, 1, false, MouseEvent.BUTTON1));
// AID_ENTER must have been transmitted
assertEquals(AID_ENTER, client.getInputProcessor().getLastAid(), "Clicking menu choice must transmit AID_ENTER");
// Keyboard lock should be standard system lock awaiting host response, NOT protected field error
assertNotEquals(com.ibm.eNetwork.ECL.ECLConstants.INHIBIT_PROTECTED_FIELD, client.getOIA().getInputInhibited(),
"Keyboard must not be locked with protected field violation");
// Option "1" must have been typed into the unprotected command input field (address inputFieldAddr + 1)
assertEquals('1', (char) sb.getCell(inputFieldAddr + 1).ucs4, "Option '1' must be placed on the command/option line");
} catch (HeadlessException ignored) {
}
}
}