Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
8608babb36
|
|||
|
1f98c0d70f
|
|||
|
3843e0fd13
|
|||
|
4754732642
|
+2
-2
@@ -11,8 +11,8 @@ android {
|
||||
applicationId "haus.nightmare.a3270"
|
||||
minSdk 24
|
||||
targetSdk 34
|
||||
versionCode 8
|
||||
versionName "1.2.3"
|
||||
versionCode 9
|
||||
versionName "1.2.4"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:versionCode="8"
|
||||
android:versionName="1.2.3">
|
||||
android:versionCode="9"
|
||||
android:versionName="1.2.4">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
@@ -555,7 +555,7 @@ fun MainScreen(
|
||||
val fieldStatus = remember(screenBuffer, cursorAddr, screenVersion) {
|
||||
val buf = screenBuffer
|
||||
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 ||
|
||||
cell.db == haus.nightmare.lib3270j.screen.ExtendedAttribute.DB_RIGHT ||
|
||||
cell.cs == haus.nightmare.lib3270j.screen.ExtendedAttribute.CS_DBCS))
|
||||
|
||||
@@ -481,9 +481,12 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
|
||||
}
|
||||
val c = client
|
||||
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
|
||||
_screenBuffer.value = buf
|
||||
if (buf != null) {
|
||||
_rows.value = buf.rows
|
||||
_cols.value = buf.cols
|
||||
_cursorAddress.value = buf.cursorAddress
|
||||
val snap = buf.displaySnapshot
|
||||
if (snap != null) {
|
||||
_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
|
||||
_isInsertMode.value = newClient.isInsertMode
|
||||
_isDocMode.value = newClient.isDocMode
|
||||
_isWordWrap.value = newClient.isWordWrap
|
||||
_isAplMode.value = newClient.isAplMode
|
||||
_screenVersion.value = System.currentTimeMillis()
|
||||
|
||||
// Drive CUT mode file transfers if active
|
||||
fileTransferCoordinator?.onScreenUpdated()
|
||||
@@ -1478,9 +1491,10 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
|
||||
|
||||
private fun getLineText(buf: ScreenBuffer, row: Int, cols: Int): String {
|
||||
val sb = StringBuilder()
|
||||
val snap = buf.displaySnapshot
|
||||
for (c in 0 until cols) {
|
||||
val addr = row * cols + c
|
||||
val cell = buf.getCell(addr)
|
||||
val cell = snap?.getCell(addr) ?: buf.getDisplayCell(addr)
|
||||
if (cell != null && !cell.isFieldAttribute) {
|
||||
val ch = cell.ucs4
|
||||
if (ch in '!'..'~' || ch.code > 127) {
|
||||
@@ -1498,9 +1512,10 @@ class TerminalViewModel(application: Application) : AndroidViewModel(application
|
||||
private fun computeScreenContentHash(buf: ScreenBuffer?): Int {
|
||||
if (buf == null) return 0
|
||||
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) {
|
||||
val cell = buf.getCell(i)
|
||||
val cell = snap?.getCell(i) ?: buf.getDisplayCell(i)
|
||||
if (cell != null && !cell.isFieldAttribute && cell.ucs4 > ' ' && cell.ucs4.code != 0xFFFF) {
|
||||
sb.append(cell.ucs4)
|
||||
}
|
||||
|
||||
@@ -93,6 +93,10 @@ class HotspotEngine {
|
||||
"(?:===>|=>|-->)\\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) {
|
||||
val s = start.coerceAtLeast(0)
|
||||
val e = end.coerceAtMost(arr.size)
|
||||
@@ -308,6 +312,33 @@ class HotspotEngine {
|
||||
hotspots.add(h)
|
||||
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 {
|
||||
if (screenBuffer == null) return ""
|
||||
screenBuffer.updateDisplaySnapshot()
|
||||
val sb = StringBuilder()
|
||||
var currentHidden = false
|
||||
|
||||
@@ -292,7 +293,7 @@ private fun generatePlainText(screenBuffer: ScreenBuffer?, rows: Int, cols: Int,
|
||||
val rowSb = StringBuilder()
|
||||
for (c in 0 until cols) {
|
||||
val addr = r * cols + c
|
||||
val cell = screenBuffer.getCell(addr)
|
||||
val cell = screenBuffer.getDisplayCell(addr) ?: continue
|
||||
if (cell.fa.toInt() != 0) {
|
||||
val faVal = cell.fa.toInt() and 0xFF
|
||||
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 {
|
||||
if (screenBuffer == null) return "<html><body><pre></pre></body></html>"
|
||||
screenBuffer.updateDisplaySnapshot()
|
||||
val sb = StringBuilder()
|
||||
sb.append("<!DOCTYPE html>\n<html>\n<head>\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 (c in 0 until cols) {
|
||||
val addr = r * cols + c
|
||||
val cell = screenBuffer.getCell(addr)
|
||||
val cell = screenBuffer.getDisplayCell(addr) ?: continue
|
||||
if (cell.fa.toInt() != 0) {
|
||||
val faVal = cell.fa.toInt() and 0xFF
|
||||
currentProt = (faVal and FA_PROTECT) != 0
|
||||
@@ -430,6 +432,7 @@ private fun generateBitmap(
|
||||
canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), bgPaint)
|
||||
|
||||
if (screenBuffer == null) return bitmap
|
||||
screenBuffer.updateDisplaySnapshot()
|
||||
|
||||
// Layer 1: Vector graphics plane (under text)
|
||||
if (graphicsPlane != null && graphicsPlane.hasContent()) {
|
||||
@@ -457,7 +460,7 @@ private fun generateBitmap(
|
||||
for (r in 0 until rows) {
|
||||
for (c in 0 until cols) {
|
||||
val addr = r * cols + c
|
||||
val cell = screenBuffer.getCell(addr)
|
||||
val cell = screenBuffer.getDisplayCell(addr) ?: continue
|
||||
val left = (c * charWidth).toFloat()
|
||||
val top = (r * charHeight).toFloat()
|
||||
|
||||
|
||||
@@ -441,7 +441,7 @@ fun SettingsDialog(
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
|
||||
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(
|
||||
checked = hotspotsMenuChoicesEnabled,
|
||||
|
||||
@@ -205,6 +205,17 @@ fun TerminalView(
|
||||
if (h.type == HotspotEngine.HotspotType.URL) {
|
||||
onOpenUrl?.invoke(h.action)
|
||||
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 {
|
||||
onSendHotspotAction?.invoke(h.action)
|
||||
handledHotspot = true
|
||||
@@ -219,10 +230,25 @@ fun TerminalView(
|
||||
if (buf != null && buf.isFormatted) {
|
||||
val faPos = buf.findFieldAttribute(clickAddr)
|
||||
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)) {
|
||||
onLightPenSelect?.invoke(clickAddr)
|
||||
isDetectable = true
|
||||
val size = buf.displayRows * buf.displayCols
|
||||
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
|
||||
if (buf != null && buf.isFormatted) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -247,10 +273,15 @@ fun TerminalView(
|
||||
}
|
||||
) {
|
||||
val version = currentScreenVersion
|
||||
val activeCursorAddr = currentCursorAddr
|
||||
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
|
||||
|
||||
val cellWidth = metrics.cellWidth
|
||||
@@ -272,8 +303,7 @@ fun TerminalView(
|
||||
paint.textSize = baseTextSize * (cellWidth * 0.85f / measuredW)
|
||||
}
|
||||
|
||||
val buf = screenBuffer
|
||||
val totalCells = rows * cols
|
||||
val totalCells = snapRows * snapCols
|
||||
|
||||
var currentFA: Byte = 0
|
||||
var currentFieldEa: ExtendedAttribute? = null
|
||||
@@ -287,10 +317,10 @@ fun TerminalView(
|
||||
val start = selectionStart
|
||||
val end = selectionEnd
|
||||
if (start != null && end != null && cellWidth > 0f && cellHeight > 0f) {
|
||||
val startCol = ((start.x - offsetX) / cellWidth).toInt().coerceIn(0, cols - 1)
|
||||
val startRow = ((start.y - offsetY) / cellHeight).toInt().coerceIn(0, rows - 1)
|
||||
val endCol = ((end.x - offsetX) / cellWidth).toInt().coerceIn(0, cols - 1)
|
||||
val endRow = ((end.y - offsetY) / cellHeight).toInt().coerceIn(0, rows - 1)
|
||||
val startCol = ((start.x - offsetX) / cellWidth).toInt().coerceIn(0, snapCols - 1)
|
||||
val startRow = ((start.y - offsetY) / cellHeight).toInt().coerceIn(0, snapRows - 1)
|
||||
val endCol = ((end.x - offsetX) / cellWidth).toInt().coerceIn(0, snapCols - 1)
|
||||
val endRow = ((end.y - offsetY) / cellHeight).toInt().coerceIn(0, snapRows - 1)
|
||||
|
||||
selMinRow = minOf(startRow, endRow)
|
||||
selMaxRow = maxOf(startRow, endRow)
|
||||
@@ -317,11 +347,11 @@ fun TerminalView(
|
||||
}
|
||||
|
||||
// 2. Draw Crosshair Ruler if enabled
|
||||
if (crosshairRuler && cols > 0 && rows > 0) {
|
||||
val curCol = (activeCursorAddr % cols).coerceIn(0, cols - 1)
|
||||
val curRow = ((activeCursorAddr / cols) % rows).coerceIn(0, rows - 1)
|
||||
val gridW = cols * cellWidth
|
||||
val gridH = rows * cellHeight
|
||||
if (crosshairRuler && snapCols > 0 && snapRows > 0) {
|
||||
val curCol = (activeCursorAddr % snapCols).coerceIn(0, snapCols - 1)
|
||||
val curRow = ((activeCursorAddr / snapCols) % snapRows).coerceIn(0, snapRows - 1)
|
||||
val gridW = snapCols * cellWidth
|
||||
val gridH = snapRows * cellHeight
|
||||
val cx = offsetX + curCol * cellWidth
|
||||
val cy = offsetY + curRow * cellHeight
|
||||
|
||||
@@ -337,9 +367,9 @@ fun TerminalView(
|
||||
)
|
||||
}
|
||||
|
||||
for (r in 0 until rows) {
|
||||
for (c in 0 until cols) {
|
||||
val addr = r * cols + c
|
||||
for (r in 0 until snapRows) {
|
||||
for (c in 0 until snapCols) {
|
||||
val addr = r * snapCols + c
|
||||
if (addr >= totalCells) break
|
||||
|
||||
val left = offsetX + c * cellWidth
|
||||
@@ -355,7 +385,7 @@ fun TerminalView(
|
||||
var csVal = 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.isFieldAttribute) {
|
||||
@@ -521,7 +551,7 @@ fun TerminalView(
|
||||
}
|
||||
|
||||
// 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
|
||||
val fEa = currentFieldEa
|
||||
val olUnderline = ExtendedAttribute.OL_UNDERLINE.toInt() and 0xFF
|
||||
@@ -536,11 +566,13 @@ fun TerminalView(
|
||||
|
||||
val size = totalCells
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -889,7 +921,7 @@ private fun copySelection(
|
||||
for (c in colStart..colEnd) {
|
||||
val addr = r * cols + c
|
||||
if (addr < (rows * cols)) {
|
||||
val cell = screenBuffer.getCell(addr)
|
||||
val cell = screenBuffer.getDisplayCell(addr)
|
||||
if (cell != null && !cell.isFieldAttribute) {
|
||||
if (cell.db == ExtendedAttribute.DB_RIGHT) {
|
||||
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]" })
|
||||
}
|
||||
|
||||
@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
|
||||
fun testHotspotHitTesting() {
|
||||
val engine = HotspotEngine()
|
||||
@@ -192,4 +291,22 @@ class HotspotAndHistoryFeaturesTest {
|
||||
assertEquals(true, hsPf)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user