4 Commits

Author SHA1 Message Date
rudi 8608babb36 Hotspot detection debugging android specific
Build and Test a3270 / Build Android APK (push) Successful in 3m34s
Release a3270 / Build & Publish Release (push) Successful in 3m18s
2026-10-02 14:23:58 +00:00
rudi 1f98c0d70f Hotspot detection debugging
Build and Test a3270 / Build Android APK (push) Successful in 1m59s
Release a3270 / Build & Publish Release (push) Successful in 5m16s
2026-10-02 13:55:18 +00:00
rudi 3843e0fd13 Fix misinterpreted if statement
Build and Test a3270 / Build Android APK (push) Successful in 5m37s
2026-09-28 04:18:51 +00:00
rudi 4754732642 Update rendering pipeline
Build and Test a3270 / Build Android APK (push) Successful in 1m59s
2026-09-28 02:17:46 +00:00
10 changed files with 445 additions and 43 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId "haus.nightmare.a3270" applicationId "haus.nightmare.a3270"
minSdk 24 minSdk 24
targetSdk 34 targetSdk 34
versionCode 8 versionCode 9
versionName "1.2.3" versionName "1.2.4"
} }
buildTypes { buildTypes {
+2 -2
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
android:versionCode="8" android:versionCode="9"
android:versionName="1.2.3"> android:versionName="1.2.4">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
@@ -555,7 +555,7 @@ fun MainScreen(
val fieldStatus = remember(screenBuffer, cursorAddr, screenVersion) { val fieldStatus = remember(screenBuffer, cursorAddr, screenVersion) {
val buf = screenBuffer val buf = screenBuffer
if (buf != null && buf.isFormatted) { if (buf != null && buf.isFormatted) {
val cell = buf.getCell(cursorAddr) val cell = buf.getDisplayCell(cursorAddr)
val isDbcs = (cell != null && (cell.db == haus.nightmare.lib3270j.screen.ExtendedAttribute.DB_LEFT || val isDbcs = (cell != null && (cell.db == haus.nightmare.lib3270j.screen.ExtendedAttribute.DB_LEFT ||
cell.db == haus.nightmare.lib3270j.screen.ExtendedAttribute.DB_RIGHT || cell.db == haus.nightmare.lib3270j.screen.ExtendedAttribute.DB_RIGHT ||
cell.cs == haus.nightmare.lib3270j.screen.ExtendedAttribute.CS_DBCS)) cell.cs == haus.nightmare.lib3270j.screen.ExtendedAttribute.CS_DBCS))
@@ -481,9 +481,12 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
} }
val c = client val c = client
if (c != null) { if (c != null) {
_cursorAddress.value = c.screenBuffer.cursorAddress _cursorAddress.value = c.screenBuffer.displayCursorAddress
val snapVersion = c.screenBuffer.snapshotVersion
_screenVersion.value = if (snapVersion > 0) snapVersion else System.currentTimeMillis()
} else {
_screenVersion.value = System.currentTimeMillis()
} }
_screenVersion.value = System.currentTimeMillis()
} }
} }
} }
@@ -1096,16 +1099,26 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
val buf = newClient.screenBuffer val buf = newClient.screenBuffer
_screenBuffer.value = buf _screenBuffer.value = buf
if (buf != null) { if (buf != null) {
_rows.value = buf.rows val snap = buf.displaySnapshot
_cols.value = buf.cols if (snap != null) {
_cursorAddress.value = buf.cursorAddress _rows.value = snap.rows
_cols.value = snap.cols
_cursorAddress.value = snap.cursorAddress
_screenVersion.value = snap.version
} else {
_rows.value = buf.displayRows
_cols.value = buf.displayCols
_cursorAddress.value = buf.displayCursorAddress
_screenVersion.value = buf.snapshotVersion
}
} else {
_screenVersion.value = System.currentTimeMillis()
} }
_isKeyboardLocked.value = newClient.inputProcessor.isKeyboardLocked _isKeyboardLocked.value = newClient.inputProcessor.isKeyboardLocked
_isInsertMode.value = newClient.isInsertMode _isInsertMode.value = newClient.isInsertMode
_isDocMode.value = newClient.isDocMode _isDocMode.value = newClient.isDocMode
_isWordWrap.value = newClient.isWordWrap _isWordWrap.value = newClient.isWordWrap
_isAplMode.value = newClient.isAplMode _isAplMode.value = newClient.isAplMode
_screenVersion.value = System.currentTimeMillis()
// Drive CUT mode file transfers if active // Drive CUT mode file transfers if active
fileTransferCoordinator?.onScreenUpdated() fileTransferCoordinator?.onScreenUpdated()
@@ -1478,9 +1491,10 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
private fun getLineText(buf: ScreenBuffer, row: Int, cols: Int): String { private fun getLineText(buf: ScreenBuffer, row: Int, cols: Int): String {
val sb = StringBuilder() val sb = StringBuilder()
val snap = buf.displaySnapshot
for (c in 0 until cols) { for (c in 0 until cols) {
val addr = row * cols + c val addr = row * cols + c
val cell = buf.getCell(addr) val cell = snap?.getCell(addr) ?: buf.getDisplayCell(addr)
if (cell != null && !cell.isFieldAttribute) { if (cell != null && !cell.isFieldAttribute) {
val ch = cell.ucs4 val ch = cell.ucs4
if (ch in '!'..'~' || ch.code > 127) { if (ch in '!'..'~' || ch.code > 127) {
@@ -1498,9 +1512,10 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
private fun computeScreenContentHash(buf: ScreenBuffer?): Int { private fun computeScreenContentHash(buf: ScreenBuffer?): Int {
if (buf == null) return 0 if (buf == null) return 0
val sb = StringBuilder() val sb = StringBuilder()
val total = buf.rows * buf.cols val snap = buf.displaySnapshot
val total = (snap?.rows ?: buf.displayRows) * (snap?.cols ?: buf.displayCols)
for (i in 0 until total) { for (i in 0 until total) {
val cell = buf.getCell(i) val cell = snap?.getCell(i) ?: buf.getDisplayCell(i)
if (cell != null && !cell.isFieldAttribute && cell.ucs4 > ' ' && cell.ucs4.code != 0xFFFF) { if (cell != null && !cell.isFieldAttribute && cell.ucs4 > ' ' && cell.ucs4.code != 0xFFFF) {
sb.append(cell.ucs4) sb.append(cell.ucs4)
} }
@@ -93,6 +93,10 @@ class HotspotEngine {
"(?:===>|=>|-->)\\s*(\\d{1,2})(?:(?=\\s)|$)" "(?:===>|=>|-->)\\s*(\\d{1,2})(?:(?=\\s)|$)"
) )
private val 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/_-]*)"
)
private fun markOccupied(arr: BooleanArray, start: Int, end: Int) { private fun markOccupied(arr: BooleanArray, start: Int, end: Int) {
val s = start.coerceAtLeast(0) val s = start.coerceAtLeast(0)
val e = end.coerceAtMost(arr.size) val e = end.coerceAtMost(arr.size)
@@ -308,6 +312,33 @@ class HotspotEngine {
hotspots.add(h) hotspots.add(h)
markOccupied(occupied, startCol, endCol) markOccupied(occupied, startCol, endCol)
} }
// 3c. Scan ISPF Menu choices (e.g. " 1 View", " 2 Edit", "10 SCLM", " X Exit", " M More", " 1 - View", " 3.1 Library")
val mIspf = ISPF_MENU_PATTERN.matcher(line)
while (mIspf.find()) {
val choiceStr = mIspf.group(1) ?: mIspf.group(2)?.uppercase() ?: continue
val labelStr = mIspf.group(3) ?: continue
val startCol = if (mIspf.group(1) != null) mIspf.start(1) else mIspf.start(2)
val endCol = mIspf.end(3)
if (isRangeOccupied(occupied, startCol, endCol)) continue
val action = "$choiceStr[enter]"
val tooltip = "Select option $choiceStr ($labelStr)"
val display = line.substring(startCol, endCol)
val h = Hotspot(
type = HotspotType.MENU_CHOICE,
startRow = r,
startCol = startCol,
endRow = r,
endCol = endCol - 1,
displayText = display,
action = action,
tooltip = tooltip
)
hotspots.add(h)
markOccupied(occupied, startCol, endCol)
}
} }
} }
@@ -285,6 +285,7 @@ fun ScreenExporterDialog(
private fun generatePlainText(screenBuffer: ScreenBuffer?, rows: Int, cols: Int, maskHidden: Boolean): String { private fun generatePlainText(screenBuffer: ScreenBuffer?, rows: Int, cols: Int, maskHidden: Boolean): String {
if (screenBuffer == null) return "" if (screenBuffer == null) return ""
screenBuffer.updateDisplaySnapshot()
val sb = StringBuilder() val sb = StringBuilder()
var currentHidden = false var currentHidden = false
@@ -292,7 +293,7 @@ private fun generatePlainText(screenBuffer: ScreenBuffer?, rows: Int, cols: Int,
val rowSb = StringBuilder() val rowSb = StringBuilder()
for (c in 0 until cols) { for (c in 0 until cols) {
val addr = r * cols + c val addr = r * cols + c
val cell = screenBuffer.getCell(addr) val cell = screenBuffer.getDisplayCell(addr) ?: continue
if (cell.fa.toInt() != 0) { if (cell.fa.toInt() != 0) {
val faVal = cell.fa.toInt() and 0xFF val faVal = cell.fa.toInt() and 0xFF
currentHidden = (faVal and FA_INT_HIGH_SEL) == 0 currentHidden = (faVal and FA_INT_HIGH_SEL) == 0
@@ -317,6 +318,7 @@ private fun generatePlainText(screenBuffer: ScreenBuffer?, rows: Int, cols: Int,
private fun generateHtml(screenBuffer: ScreenBuffer?, rows: Int, cols: Int, maskHidden: Boolean): String { private fun generateHtml(screenBuffer: ScreenBuffer?, rows: Int, cols: Int, maskHidden: Boolean): String {
if (screenBuffer == null) return "<html><body><pre></pre></body></html>" if (screenBuffer == null) return "<html><body><pre></pre></body></html>"
screenBuffer.updateDisplaySnapshot()
val sb = StringBuilder() val sb = StringBuilder()
sb.append("<!DOCTYPE html>\n<html>\n<head>\n") sb.append("<!DOCTYPE html>\n<html>\n<head>\n")
sb.append("<meta charset=\"UTF-8\">\n") sb.append("<meta charset=\"UTF-8\">\n")
@@ -343,7 +345,7 @@ private fun generateHtml(screenBuffer: ScreenBuffer?, rows: Int, cols: Int, mask
for (r in 0 until rows) { for (r in 0 until rows) {
for (c in 0 until cols) { for (c in 0 until cols) {
val addr = r * cols + c val addr = r * cols + c
val cell = screenBuffer.getCell(addr) val cell = screenBuffer.getDisplayCell(addr) ?: continue
if (cell.fa.toInt() != 0) { if (cell.fa.toInt() != 0) {
val faVal = cell.fa.toInt() and 0xFF val faVal = cell.fa.toInt() and 0xFF
currentProt = (faVal and FA_PROTECT) != 0 currentProt = (faVal and FA_PROTECT) != 0
@@ -430,6 +432,7 @@ private fun generateBitmap(
canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), bgPaint) canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), bgPaint)
if (screenBuffer == null) return bitmap if (screenBuffer == null) return bitmap
screenBuffer.updateDisplaySnapshot()
// Layer 1: Vector graphics plane (under text) // Layer 1: Vector graphics plane (under text)
if (graphicsPlane != null && graphicsPlane.hasContent()) { if (graphicsPlane != null && graphicsPlane.hasContent()) {
@@ -457,7 +460,7 @@ private fun generateBitmap(
for (r in 0 until rows) { for (r in 0 until rows) {
for (c in 0 until cols) { for (c in 0 until cols) {
val addr = r * cols + c val addr = r * cols + c
val cell = screenBuffer.getCell(addr) val cell = screenBuffer.getDisplayCell(addr) ?: continue
val left = (c * charWidth).toFloat() val left = (c * charWidth).toFloat()
val top = (r * charHeight).toFloat() val top = (r * charHeight).toFloat()
@@ -441,7 +441,7 @@ fun SettingsDialog(
) { ) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) { Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text("Auto-Detect Menu Choices", fontSize = 13.sp, fontWeight = FontWeight.Medium, color = Color.White) Text("Auto-Detect Menu Choices", fontSize = 13.sp, fontWeight = FontWeight.Medium, color = Color.White)
Text("\"1.\", \"2.\", \"===> 3\", \"=> 4\"", fontSize = 11.sp, color = Color.LightGray) Text("ISPF, \"1.\", \"2.\", \"===> 3\", \"=> 4\"", fontSize = 11.sp, color = Color.LightGray)
} }
Switch( Switch(
checked = hotspotsMenuChoicesEnabled, checked = hotspotsMenuChoicesEnabled,
@@ -205,6 +205,17 @@ fun TerminalView(
if (h.type == HotspotEngine.HotspotType.URL) { if (h.type == HotspotEngine.HotspotType.URL) {
onOpenUrl?.invoke(h.action) onOpenUrl?.invoke(h.action)
handledHotspot = true handledHotspot = true
} else if (h.type == HotspotEngine.HotspotType.MENU_CHOICE) {
// In 3270 / ISPF, clicking/tapping 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]).
var action = h.action
if (!action.startsWith("[home]")) {
action = "[home][eraseeof]$action"
}
onSendHotspotAction?.invoke(action)
handledHotspot = true
} else { } else {
onSendHotspotAction?.invoke(h.action) onSendHotspotAction?.invoke(h.action)
handledHotspot = true handledHotspot = true
@@ -219,10 +230,25 @@ fun TerminalView(
if (buf != null && buf.isFormatted) { if (buf != null && buf.isFormatted) {
val faPos = buf.findFieldAttribute(clickAddr) val faPos = buf.findFieldAttribute(clickAddr)
if (faPos >= 0) { if (faPos >= 0) {
val fa = buf.getCell(faPos).fa.toInt() and 0xFF val fa = buf.getDisplayCell(faPos)?.fa?.toInt()?.and(0xFF) ?: 0
if (faIsSelectable(fa) && !faIsZero(fa)) { if (faIsSelectable(fa) && !faIsZero(fa)) {
onLightPenSelect?.invoke(clickAddr) val size = buf.displayRows * buf.displayCols
isDetectable = true val desPos = if (size > 0) (faPos + 1) % size else -1
val desCell = if (desPos >= 0) buf.getDisplayCell(desPos) else null
val desAscii = desCell?.ucs4 ?: 0.toChar()
val desEbcdic = desCell?.ec?.toInt()?.and(0xFF) ?: 0
val isToggleField = (desEbcdic == 0x6F || desAscii == '?' || desAscii == '\uFF1F' ||
desEbcdic == 0x6E || desAscii == '>' || desAscii == '\uFF1E')
val isEnterField = (desEbcdic == 0x50 || desAscii == '&' || desAscii == '\uFF06')
// Automatic tap selection applies only to toggle (?/>) and enter (&) fields.
// Standard text fields (space/null designators) must not trigger light-pen selection
// so normal cursor positioning and editing work as expected.
if (isToggleField || isEnterField) {
onLightPenSelect?.invoke(clickAddr)
isDetectable = true
}
} }
} }
} }
@@ -232,7 +258,7 @@ fun TerminalView(
var addr = clickAddr var addr = clickAddr
if (buf != null && buf.isFormatted) { if (buf != null && buf.isFormatted) {
val faVal = buf.getFieldAttributeAt(addr) val faVal = buf.getFieldAttributeAt(addr)
if (faIsProtected(faVal.toInt() and 0xFF) || buf.getCell(addr).isFieldAttribute) { if (faIsProtected(faVal.toInt() and 0xFF) || buf.getDisplayCell(addr)?.isFieldAttribute == true) {
addr = buf.findNextUnprotected(addr) addr = buf.findNextUnprotected(addr)
} }
} }
@@ -247,10 +273,15 @@ fun TerminalView(
} }
) { ) {
val version = currentScreenVersion val version = currentScreenVersion
val activeCursorAddr = currentCursorAddr
if (version == -1L) return@Canvas if (version == -1L) return@Canvas
val metrics = calculateGridMetrics(size.width, size.height, cols, rows) val buf = screenBuffer
val snapshot = buf?.displaySnapshot
val snapRows = snapshot?.rows ?: (buf?.displayRows ?: rows)
val snapCols = snapshot?.cols ?: (buf?.displayCols ?: cols)
val activeCursorAddr = snapshot?.cursorAddress ?: currentCursorAddr
val metrics = calculateGridMetrics(size.width, size.height, snapCols, snapRows)
if (metrics.cellWidth <= 0f || metrics.cellHeight <= 0f) return@Canvas if (metrics.cellWidth <= 0f || metrics.cellHeight <= 0f) return@Canvas
val cellWidth = metrics.cellWidth val cellWidth = metrics.cellWidth
@@ -272,8 +303,7 @@ fun TerminalView(
paint.textSize = baseTextSize * (cellWidth * 0.85f / measuredW) paint.textSize = baseTextSize * (cellWidth * 0.85f / measuredW)
} }
val buf = screenBuffer val totalCells = snapRows * snapCols
val totalCells = rows * cols
var currentFA: Byte = 0 var currentFA: Byte = 0
var currentFieldEa: ExtendedAttribute? = null var currentFieldEa: ExtendedAttribute? = null
@@ -287,10 +317,10 @@ fun TerminalView(
val start = selectionStart val start = selectionStart
val end = selectionEnd val end = selectionEnd
if (start != null && end != null && cellWidth > 0f && cellHeight > 0f) { if (start != null && end != null && cellWidth > 0f && cellHeight > 0f) {
val startCol = ((start.x - offsetX) / cellWidth).toInt().coerceIn(0, cols - 1) val startCol = ((start.x - offsetX) / cellWidth).toInt().coerceIn(0, snapCols - 1)
val startRow = ((start.y - offsetY) / cellHeight).toInt().coerceIn(0, rows - 1) val startRow = ((start.y - offsetY) / cellHeight).toInt().coerceIn(0, snapRows - 1)
val endCol = ((end.x - offsetX) / cellWidth).toInt().coerceIn(0, cols - 1) val endCol = ((end.x - offsetX) / cellWidth).toInt().coerceIn(0, snapCols - 1)
val endRow = ((end.y - offsetY) / cellHeight).toInt().coerceIn(0, rows - 1) val endRow = ((end.y - offsetY) / cellHeight).toInt().coerceIn(0, snapRows - 1)
selMinRow = minOf(startRow, endRow) selMinRow = minOf(startRow, endRow)
selMaxRow = maxOf(startRow, endRow) selMaxRow = maxOf(startRow, endRow)
@@ -317,11 +347,11 @@ fun TerminalView(
} }
// 2. Draw Crosshair Ruler if enabled // 2. Draw Crosshair Ruler if enabled
if (crosshairRuler && cols > 0 && rows > 0) { if (crosshairRuler && snapCols > 0 && snapRows > 0) {
val curCol = (activeCursorAddr % cols).coerceIn(0, cols - 1) val curCol = (activeCursorAddr % snapCols).coerceIn(0, snapCols - 1)
val curRow = ((activeCursorAddr / cols) % rows).coerceIn(0, rows - 1) val curRow = ((activeCursorAddr / snapCols) % snapRows).coerceIn(0, snapRows - 1)
val gridW = cols * cellWidth val gridW = snapCols * cellWidth
val gridH = rows * cellHeight val gridH = snapRows * cellHeight
val cx = offsetX + curCol * cellWidth val cx = offsetX + curCol * cellWidth
val cy = offsetY + curRow * cellHeight val cy = offsetY + curRow * cellHeight
@@ -337,9 +367,9 @@ fun TerminalView(
) )
} }
for (r in 0 until rows) { for (r in 0 until snapRows) {
for (c in 0 until cols) { for (c in 0 until snapCols) {
val addr = r * cols + c val addr = r * snapCols + c
if (addr >= totalCells) break if (addr >= totalCells) break
val left = offsetX + c * cellWidth val left = offsetX + c * cellWidth
@@ -355,7 +385,7 @@ fun TerminalView(
var csVal = 0 var csVal = 0
var ecVal = 0 var ecVal = 0
val ea = if (buf != null) buf.getCell(addr) else null val ea = snapshot?.getCell(addr) ?: buf?.getDisplayCell(addr)
if (ea != null) { if (ea != null) {
if (ea.isFieldAttribute) { if (ea.isFieldAttribute) {
@@ -521,7 +551,7 @@ fun TerminalView(
} }
// Field Outlining (0xC2 / TY_FOUTLINE) // Field Outlining (0xC2 / TY_FOUTLINE)
val cellEa = if (buf != null) buf.getCell(addr) else null val cellEa = snapshot?.getCell(addr) ?: buf?.getDisplayCell(addr)
var outline = if (cellEa != null) cellEa.ol.toInt() and 0xFF else 0 var outline = if (cellEa != null) cellEa.ol.toInt() and 0xFF else 0
val fEa = currentFieldEa val fEa = currentFieldEa
val olUnderline = ExtendedAttribute.OL_UNDERLINE.toInt() and 0xFF val olUnderline = ExtendedAttribute.OL_UNDERLINE.toInt() and 0xFF
@@ -536,11 +566,13 @@ fun TerminalView(
val size = totalCells val size = totalCells
val prevAddr = (addr - 1 + size) % size val prevAddr = (addr - 1 + size) % size
if (buf != null && (buf.getDisplayCell(prevAddr)?.isFieldAttribute == true || c == 0)) { val prevCell = snapshot?.getCell(prevAddr) ?: buf?.getDisplayCell(prevAddr)
if (prevCell?.isFieldAttribute == true || c == 0) {
if ((fol and olLeft) != 0) outline = outline or olLeft if ((fol and olLeft) != 0) outline = outline or olLeft
} }
val nextAddr = (addr + 1) % size val nextAddr = (addr + 1) % size
if (buf != null && (buf.getDisplayCell(nextAddr)?.isFieldAttribute == true || c == cols - 1)) { val nextCell = snapshot?.getCell(nextAddr) ?: buf?.getDisplayCell(nextAddr)
if (nextCell?.isFieldAttribute == true || c == snapCols - 1) {
if ((fol and olRight) != 0) outline = outline or olRight if ((fol and olRight) != 0) outline = outline or olRight
} }
} }
@@ -889,7 +921,7 @@ private fun copySelection(
for (c in colStart..colEnd) { for (c in colStart..colEnd) {
val addr = r * cols + c val addr = r * cols + c
if (addr < (rows * cols)) { if (addr < (rows * cols)) {
val cell = screenBuffer.getCell(addr) val cell = screenBuffer.getDisplayCell(addr)
if (cell != null && !cell.isFieldAttribute) { if (cell != null && !cell.isFieldAttribute) {
if (cell.db == ExtendedAttribute.DB_RIGHT) { if (cell.db == ExtendedAttribute.DB_RIGHT) {
val isSubsequent = if (blockSelectMode) c > minCol else (addr > (minRow * cols + minCol)) val isSubsequent = if (blockSelectMode) c > minCol else (addr > (minRow * cols + minCol))
@@ -0,0 +1,204 @@
package haus.nightmare.a3270
import haus.nightmare.lib3270j.TerminalModel
import haus.nightmare.lib3270j.charset.EbcdicTranslator
import haus.nightmare.lib3270j.screen.ExtendedAttribute
import haus.nightmare.lib3270j.screen.ScreenBuffer
import org.junit.Assert.*
import org.junit.Test
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong
class DisplaySnapshotIntegrationTest {
@Test
fun testSnapshotCaptureAndLockFreeReads() {
val translator = EbcdicTranslator()
val sb = ScreenBuffer(TerminalModel.IBM_3279_2, translator)
sb.erase(false)
val initialVersion = sb.snapshotVersion
sb.getCell(0).ucs4 = 'H'
sb.getCell(1).ucs4 = 'E'
sb.getCell(2).ucs4 = 'L'
sb.getCell(3).ucs4 = 'L'
sb.getCell(4).ucs4 = 'O'
sb.setCursorAddress(42)
sb.updateDisplaySnapshot()
val snap = sb.displaySnapshot
assertNotNull("DisplaySnapshot must not be null after updateDisplaySnapshot()", snap)
assertTrue("Snapshot version must increase", sb.snapshotVersion > initialVersion)
assertEquals(24, snap.rows)
assertEquals(80, snap.cols)
assertEquals(42, snap.cursorAddress)
assertEquals('H', snap.getCell(0)?.ucs4)
assertEquals('E', snap.getCell(1)?.ucs4)
assertEquals('L', snap.getCell(2)?.ucs4)
assertEquals('L', snap.getCell(3)?.ucs4)
assertEquals('O', snap.getCell(4)?.ucs4)
// getDisplayCell fallback
assertEquals('H', sb.getDisplayCell(0)?.ucs4)
assertEquals(42, sb.displayCursorAddress)
assertEquals(24, sb.displayRows)
assertEquals(80, sb.displayCols)
}
@Test
fun testTearFreeRenderingUnderConcurrentBufferMutation() {
val translator = EbcdicTranslator()
val sb = ScreenBuffer(TerminalModel.IBM_3279_2, translator)
sb.erase(false)
val size = 24 * 80
val totalUpdates = 2000
val workerCount = 4
val executor = Executors.newFixedThreadPool(workerCount + 1)
val running = AtomicBoolean(true)
val tornCellCount = AtomicInteger(0)
val exceptionCount = AtomicInteger(0)
val totalReads = AtomicLong(0)
val startLatch = CountDownLatch(1)
val writersDoneLatch = CountDownLatch(1)
// Reader workers simulating Compose Canvas draw passes
for (w in 0 until workerCount) {
executor.submit {
try {
startLatch.await()
while (running.get()) {
val snap = sb.displaySnapshot ?: continue
val snapSize = snap.rows * snap.cols
for (addr in 0 until snapSize) {
val cell = snap.getCell(addr) ?: continue
totalReads.incrementAndGet()
// Patterned validation:
// ucs4 = 'A' + mod, ec = 0x10 + mod, fg = 0x30 + mod, bg = 0x50 + mod
if (cell.ucs4 in 'A'..'Z') {
val ucsOffset = cell.ucs4 - 'A'
val ecOffset = (cell.ec.toInt() and 0xFF) - 0x10
val fgOffset = (cell.fg.toInt() and 0xFF) - 0x30
val bgOffset = (cell.bg.toInt() and 0xFF) - 0x50
if (ucsOffset != ecOffset || ucsOffset != fgOffset || ucsOffset != bgOffset) {
tornCellCount.incrementAndGet()
}
}
}
}
} catch (t: Throwable) {
exceptionCount.incrementAndGet()
}
}
}
// Writer task simulating background Telnet network receiver thread
executor.submit {
try {
startLatch.await()
for (iter in 0 until totalUpdates) {
val mod = iter % 26
val ch = ('A' + mod)
val ec = (0x10 + mod).toByte()
val fg = (0x30 + mod).toByte()
val bg = (0x50 + mod).toByte()
val rawBuf = sb.buffer
for (i in 0 until size) {
val cell = rawBuf[i]
cell.ucs4 = ch
cell.ec = ec
cell.fg = fg
cell.bg = bg
}
sb.setCursorAddress(iter % size)
sb.updateDisplaySnapshot()
}
} catch (t: Throwable) {
exceptionCount.incrementAndGet()
} finally {
writersDoneLatch.countDown()
}
}
startLatch.countDown()
val finished = writersDoneLatch.await(10, TimeUnit.SECONDS)
assertTrue("Writer must complete updates without deadlock", finished)
running.set(false)
executor.shutdown()
assertTrue("Executor must terminate cleanly", executor.awaitTermination(5, TimeUnit.SECONDS))
assertEquals("Zero exceptions must occur during concurrent execution", 0, exceptionCount.get())
assertEquals("Zero torn cell states must occur during DisplaySnapshot access", 0, tornCellCount.get())
assertTrue("Total reads must be significant", totalReads.get() > 50_000)
}
@Test
fun testConcurrentReadsDuringScreenResizing() {
val translator = EbcdicTranslator()
val sb = ScreenBuffer(24, 80, 27, 132, translator)
val workerCount = 4
val executor = Executors.newFixedThreadPool(workerCount + 1)
val running = AtomicBoolean(true)
val exceptionCount = AtomicInteger(0)
val successfulReads = AtomicLong(0)
val startLatch = CountDownLatch(1)
val writerLatch = CountDownLatch(1)
for (w in 0 until workerCount) {
executor.submit {
try {
startLatch.await()
while (running.get()) {
val snap = sb.displaySnapshot ?: continue
val total = snap.rows * snap.cols
for (i in 0 until total) {
val cell = snap.getCell(i)
if (cell != null) {
successfulReads.incrementAndGet()
}
}
}
} catch (t: Throwable) {
exceptionCount.incrementAndGet()
}
}
}
// Writer alternates dimensions between 24x80 and 27x132
executor.submit {
try {
startLatch.await()
for (iter in 0 until 500) {
sb.erase((iter % 2) != 0)
}
} catch (t: Throwable) {
exceptionCount.incrementAndGet()
} finally {
writerLatch.countDown()
}
}
startLatch.countDown()
assertTrue("Resize writer must complete without deadlock", writerLatch.await(10, TimeUnit.SECONDS))
running.set(false)
executor.shutdown()
assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS))
assertEquals("Zero exceptions must occur during dynamic dimension resizing", 0, exceptionCount.get())
assertTrue("Successful reads across dimension resizing", successfulReads.get() > 20_000)
}
}
@@ -54,6 +54,105 @@ class HotspotAndHistoryFeaturesTest {
assertTrue(enterHotspots.any { it.action == "[enter]" }) assertTrue(enterHotspots.any { it.action == "[enter]" })
} }
@Test
fun testIspfPrimaryOptionMenuDetection() {
val engine = HotspotEngine()
val 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"
val 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)
val menuChoices = hotspots.filter { it.type == HotspotType.MENU_CHOICE }
assertEquals(13, menuChoices.size)
// Option 0
val h0 = menuChoices[0]
assertEquals(HotspotType.MENU_CHOICE, h0.type)
assertEquals("0 Settings", h0.displayText)
assertEquals("0[enter]", h0.action)
assertEquals("Select option 0 (Settings)", h0.tooltip)
assertEquals(5, h0.startRow)
// Option 1
val h1 = menuChoices[1]
assertEquals("1 View", h1.displayText)
assertEquals("1[enter]", h1.action)
assertEquals("Select option 1 (View)", h1.tooltip)
assertEquals(6, h1.startRow)
// Option 2
val h2 = menuChoices[2]
assertEquals("2 Edit", h2.displayText)
assertEquals("2[enter]", h2.action)
assertEquals("Select option 2 (Edit)", h2.tooltip)
// Option 10 (two-digit, index 9 after 0,1,2,3,4,5,6,7,9)
val h10 = menuChoices[9]
assertEquals("10 SCLM", h10.displayText)
assertEquals("10[enter]", h10.action)
assertEquals("Select option 10 (SCLM)", h10.tooltip)
// Option M (letter, index 11)
val hM = menuChoices[11]
assertEquals("M More", hM.displayText)
assertEquals("M[enter]", hM.action)
assertEquals("Select option M (More)", hM.tooltip)
// Option X (exit, index 12)
val hX = menuChoices[12]
assertEquals("X Exit", hX.displayText)
assertEquals("X[enter]", hX.action)
assertEquals("Select option X (Exit)", hX.tooltip)
}
@Test
fun testIspfSubOptionsAndMultiColumn() {
val engine = HotspotEngine()
val screen =
" 3.1 Library - Compress or print data set\n" +
" 3.2 DataSet - Allocate, rename, or delete\n" +
" 1 - View 2 - Edit"
val hotspots = engine.scanText(screen, 3, 80)
assertEquals(4, hotspots.size)
val h31 = hotspots[0]
assertEquals("3.1 Library", h31.displayText)
assertEquals("3.1[enter]", h31.action)
assertEquals("Select option 3.1 (Library)", h31.tooltip)
val h32 = hotspots[1]
assertEquals("3.2 DataSet", h32.displayText)
assertEquals("3.2[enter]", h32.action)
val h1 = hotspots[2]
assertEquals("1 - View", h1.displayText)
assertEquals("1[enter]", h1.action)
val h2 = hotspots[3]
assertEquals("2 - Edit", h2.displayText)
assertEquals("2[enter]", h2.action)
}
@Test @Test
fun testHotspotHitTesting() { fun testHotspotHitTesting() {
val engine = HotspotEngine() val engine = HotspotEngine()
@@ -192,4 +291,22 @@ class HotspotAndHistoryFeaturesTest {
assertEquals(true, hsPf) assertEquals(true, hsPf)
assertEquals(false, hsChoices) assertEquals(false, hsChoices)
} }
@Test
fun testMenuChoiceActionPrefixingForSafeSubmission() {
val engine = HotspotEngine()
val screen = " 1 View Display source data or listings"
val hotspots = engine.scanText(screen, 1, 80)
assertEquals(1, hotspots.size)
val h = hotspots[0]
assertEquals(HotspotType.MENU_CHOICE, h.type)
assertEquals("1[enter]", h.action)
// Menu choices must be submitted via [home][eraseeof] to prevent protected field keyboard lock
var safeAction = h.action
if (!safeAction.startsWith("[home]")) {
safeAction = "[home][eraseeof]$safeAction"
}
assertEquals("[home][eraseeof]1[enter]", safeAction)
}
} }