Bugfixes from zOS testing
Build and Test j3270 / Build JAR & Run Tests (Java 11) (push) Successful in 37s
Build and Test j3270 / Build JAR & Run Tests (Java 17) (push) Successful in 1m16s
Build and Test j3270 / Build JAR & Run Tests (Java 21) (push) Successful in 1m52s

This commit is contained in:
2026-09-17 23:09:43 +00:00
parent 30b1a77b61
commit ef70ea0f5a
25 changed files with 2686 additions and 335 deletions
@@ -0,0 +1,476 @@
package haus.nightmare.lib3270j.ft;
import haus.nightmare.lib3270j.TerminalModel;
import haus.nightmare.lib3270j.charset.CodePageRegistry;
import haus.nightmare.lib3270j.charset.EbcdicTranslator;
import haus.nightmare.lib3270j.input.InputProcessor;
import haus.nightmare.lib3270j.screen.ScreenBuffer;
import static haus.nightmare.lib3270j.ft.FTConstants.*;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
/**
* Phase 16 Test Suite: IND$FILE Host File Transfer Protocol Hardening.
* Validates 1:1 packet, timing, and record-layout compatibility with IBM Host On-Demand v14.
*/
public class FTProtocolHardeningPhase16Test {
private EbcdicTranslator translator;
private ScreenBuffer screen;
private MockInputProcessor input;
private MockDftListener listener;
private FTDft ftDft;
static class MockInputProcessor extends InputProcessor {
final List<byte[]> sentStructuredFields = new ArrayList<>();
public MockInputProcessor(ScreenBuffer screen, EbcdicTranslator translator) {
super(screen, translator, null);
}
@Override
public void sendStructuredFieldData(byte[] data) {
sentStructuredFields.add(data.clone());
}
}
static class MockDftListener implements FTDft.FTDftListener {
FTConfig config = new FTConfig();
FTState state = FTState.RUNNING;
long bytesTransferred = 0;
boolean completeCalled = false;
boolean abortCalled = false;
String abortMessage = null;
@Override
public void onDftRunning() {
state = FTState.RUNNING;
}
@Override
public void onTransferComplete(String errorMessage) {
state = FTState.NONE;
completeCalled = true;
}
@Override
public void onTransferAborted(String errorMessage) {
state = FTState.NONE;
abortCalled = true;
abortMessage = errorMessage;
}
@Override
public void onBytesTransferred(long bytes) {
this.bytesTransferred = bytes;
}
@Override
public FTState getCurrentState() {
return state;
}
@Override
public void setState(FTState state) {
this.state = state;
}
@Override
public FTConfig getConfig() {
return config;
}
@Override
public File getLocalFile() {
return null;
}
}
@BeforeEach
public void setUp() {
translator = new EbcdicTranslator();
screen = new ScreenBuffer(TerminalModel.IBM_3278_2, translator);
input = new MockInputProcessor(screen, translator);
listener = new MockDftListener();
ftDft = new FTDft(input, translator, listener);
}
@Test
public void testHoDBinaryPacketTemplates() {
// 1. RB_OPEN_ACK: 8 bytes matching HoD Xfer3270 lines 553-561
byte[] expectedOpenAck = {
(byte) 0x88, 0x00, 0x05, (byte) 0xD0, 0x00, 0x09, (byte) 0xFF, (byte) 0xEF
};
assertArrayEquals(expectedOpenAck, RB_OPEN_ACK);
// 2. RB_CLOSE_ACK: 8 bytes matching HoD Xfer3270 lines 578-586
byte[] expectedCloseAck = {
(byte) 0x88, 0x00, 0x05, (byte) 0xD0, 0x41, 0x09, (byte) 0xFF, (byte) 0xEF
};
assertArrayEquals(expectedCloseAck, RB_CLOSE_ACK);
// 3. RB_GET_PAST_EOF: 12 bytes matching HoD Xfer3270 lines 795-806
byte[] expectedEof = {
(byte) 0x88, 0x00, 0x09, (byte) 0xD0, 0x46, 0x08, 0x69, 0x04, 0x22, 0x00, (byte) 0xFF, (byte) 0xEF
};
assertArrayEquals(expectedEof, RB_GET_PAST_EOF);
// 4. RB_OPEN_FAILED: 11 bytes matching HoD Xfer3270 lines 588-599
byte[] expectedOpenFailed = {
0x00, 0x09, (byte) 0xD0, 0x00, 0x08, 0x69, 0x04, 0x01, 0x00, (byte) 0xFF, (byte) 0xEF
};
assertArrayEquals(expectedOpenFailed, RB_OPEN_FAILED);
// 5. createDataAck(recnum): 14 bytes matching HoD Xfer3270 lines 563-576 + doubleFoxFox
byte[] ack1 = createDataAck(1);
assertEquals(14, ack1.length);
assertEquals((byte) 0x88, ack1[0]);
assertEquals(0x00, ack1[1]);
assertEquals(0x0B, ack1[2]);
assertEquals((byte) 0xD0, ack1[3]);
assertEquals(0x47, ack1[4]);
assertEquals(0x05, ack1[5]);
assertEquals(0x63, ack1[6]);
assertEquals(0x06, ack1[7]);
assertEquals(0x00, ack1[8]);
assertEquals(0x00, ack1[9]);
assertEquals(0x00, ack1[10]);
assertEquals(0x01, ack1[11]);
assertEquals((byte) 0xFF, ack1[12]);
assertEquals((byte) 0xEF, ack1[13]);
byte[] ackLarge = createDataAck(0x12345678L);
assertEquals(0x12, ackLarge[8] & 0xFF);
assertEquals(0x34, ackLarge[9] & 0xFF);
assertEquals(0x56, ackLarge[10] & 0xFF);
assertEquals(0x78, ackLarge[11] & 0xFF);
}
@Test
public void testOpenRequestSendsExactHoDOpenAck() {
ByteArrayInputStream in = new ByteArrayInputStream(new byte[0]);
ByteArrayOutputStream out = new ByteArrayOutputStream();
ftDft.initTransfer(in, out);
// Build Open Request SF
byte[] openSf = {
0x00, 0x05, (byte) 0xD0, 0x00, 0x12
};
ftDft.processStructuredField(openSf, 0, openSf.length);
assertEquals(1, input.sentStructuredFields.size());
assertArrayEquals(RB_OPEN_ACK, input.sentStructuredFields.get(0));
}
@Test
public void testCloseRequestFlushesAndSendsExactHoDCloseAck() {
ByteArrayInputStream in = new ByteArrayInputStream(new byte[0]);
ByteArrayOutputStream out = new ByteArrayOutputStream();
ftDft.initTransfer(in, out);
byte[] closeSf = {
0x00, 0x05, (byte) 0xD0, 0x41, 0x12
};
ftDft.processStructuredField(closeSf, 0, closeSf.length);
assertEquals(1, input.sentStructuredFields.size());
assertArrayEquals(RB_CLOSE_ACK, input.sentStructuredFields.get(0));
assertTrue(listener.completeCalled);
}
@Test
public void testUploadEofSendsExactHoDGetPastEof() {
ByteArrayInputStream in = new ByteArrayInputStream(new byte[0]);
ByteArrayOutputStream out = new ByteArrayOutputStream();
ftDft.initTransfer(in, out);
// Host sends TR_GET_REQ (0x4611)
byte[] getSf = {
0x00, 0x05, (byte) 0xD0, 0x46, 0x11
};
ftDft.processStructuredField(getSf, 0, getSf.length);
assertEquals(1, input.sentStructuredFields.size());
assertArrayEquals(RB_GET_PAST_EOF, input.sentStructuredFields.get(0));
}
@Test
public void testDataAckLayoutOnDownload() {
ByteArrayInputStream in = new ByteArrayInputStream(new byte[0]);
ByteArrayOutputStream out = new ByteArrayOutputStream();
ftDft.initTransfer(in, out);
// Host sends Data Insert SF with 4 data bytes
byte[] dataInsert = {
0x00, 0x0C, (byte) 0xD0, 0x47, 0x04,
0x61, 0x00, 0x09, // TR_BEGIN_DATA + len (9 = 4 data + 5)
'T', 'E', 'S', 'T'
};
ftDft.processStructuredField(dataInsert, 0, dataInsert.length);
assertEquals(1, input.sentStructuredFields.size());
byte[] expectedAck = createDataAck(1);
assertArrayEquals(expectedAck, input.sentStructuredFields.get(0));
}
@Test
public void testHostErrorRepliesMappedToDescriptions() {
ByteArrayInputStream in = new ByteArrayInputStream(new byte[0]);
ByteArrayOutputStream out = new ByteArrayOutputStream();
ftDft.initTransfer(in, out);
// 1. TR_ERR_OPEN_FAILED (0x0100)
byte[] errOpenFailed = {
0x00, 0x09, (byte) 0xD0, 0x00, 0x08, 0x69, 0x04, 0x01, 0x00
};
ftDft.processStructuredField(errOpenFailed, 0, errOpenFailed.length);
assertTrue(listener.abortCalled);
assertTrue(listener.abortMessage.toLowerCase().contains("file not found") ||
listener.abortMessage.toLowerCase().contains("open failed"));
// Reset listener
listener.abortCalled = false;
listener.abortMessage = null;
// 2. TR_ERR_NOT_AUTHORIZED (0x3E00)
byte[] errNotAuth = {
0x00, 0x09, (byte) 0xD0, 0x00, 0x08, 0x69, 0x04, 0x3E, 0x00
};
ftDft.processStructuredField(errNotAuth, 0, errNotAuth.length);
assertTrue(listener.abortCalled);
assertTrue(listener.abortMessage.toLowerCase().contains("not authorized") ||
listener.abortMessage.toLowerCase().contains("security"));
// 3. TR_ERR_SYNTAX_ERROR (0x6000)
listener.abortCalled = false;
byte[] errSyntax = {
0x00, 0x09, (byte) 0xD0, 0x00, 0x08, 0x69, 0x04, 0x60, 0x00
};
ftDft.processStructuredField(errSyntax, 0, errSyntax.length);
assertTrue(listener.abortCalled);
assertTrue(listener.abortMessage.toLowerCase().contains("syntax error") ||
listener.abortMessage.toLowerCase().contains("options"));
}
@Test
public void testStreamNameDetectionAsciiAndEbcdic() {
ByteArrayInputStream in = new ByteArrayInputStream(new byte[0]);
ByteArrayOutputStream out = new ByteArrayOutputStream();
ftDft.initTransfer(in, out);
// Open request containing EBCDIC FT:MSG
ByteArrayOutputStream sfMsg = new ByteArrayOutputStream();
sfMsg.write(0x00); sfMsg.write(0x10);
sfMsg.write(SF_TRANSFER_DATA);
sfMsg.write(0x00); sfMsg.write(0x12);
sfMsg.write(STREAM_NAME_FT_MSG_BYTES_EBCDIC, 0, STREAM_NAME_FT_MSG_BYTES_EBCDIC.length);
byte[] msgPacket = sfMsg.toByteArray();
msgPacket[0] = (byte) ((msgPacket.length >> 8) & 0xFF);
msgPacket[1] = (byte) (msgPacket.length & 0xFF);
ftDft.processStructuredField(msgPacket, 0, msgPacket.length);
assertEquals(FTState.RUNNING, listener.state);
}
@Test
public void testRecfmFPaddingAndSplittingUpload() {
FTConfig config = new FTConfig();
config.setDirection(FTConfig.Direction.SEND);
config.setTransferMode(FTConfig.TransferMode.ASCII);
config.setRecfm(FTConfig.RecordFormat.FIXED);
config.setLrecl("10");
listener.config = config;
// Local content has two lines: "SHORT" (5 chars) and "LONGER LINE THAN TEN" (20 chars)
String localContent = "SHORT\nLONGER LINE THAN TEN\n";
ByteArrayInputStream in = new ByteArrayInputStream(localContent.getBytes(StandardCharsets.UTF_8));
ByteArrayOutputStream out = new ByteArrayOutputStream();
ftDft.initTransfer(in, out);
// Host sends TR_GET_REQ
byte[] getSf = { 0x00, 0x05, (byte) 0xD0, 0x46, 0x11 };
ftDft.processStructuredField(getSf, 0, getSf.length);
assertEquals(1, input.sentStructuredFields.size());
byte[] reply = input.sentStructuredFields.get(0);
int headerLen = 17;
int dataLen = reply.length - headerLen;
assertTrue(dataLen >= 30, "Should have packed at least 3 fixed records of 10 bytes each");
String rec1 = new String(reply, headerLen, 10, StandardCharsets.UTF_8);
assertEquals("SHORT ", rec1);
String rec2 = new String(reply, headerLen + 10, 10, StandardCharsets.UTF_8);
assertEquals("LONGER LIN", rec2);
String rec3 = new String(reply, headerLen + 20, 10, StandardCharsets.UTF_8);
assertEquals("E THAN TEN", rec3);
}
@Test
public void testRecfmFDownloadTrimmingAndNewline() throws Exception {
FTConfig config = new FTConfig();
config.setDirection(FTConfig.Direction.RECEIVE);
config.setTransferMode(FTConfig.TransferMode.ASCII);
config.setRecfm(FTConfig.RecordFormat.FIXED);
config.setLrecl("10");
listener.config = config;
ByteArrayInputStream in = new ByteArrayInputStream(new byte[0]);
ByteArrayOutputStream out = new ByteArrayOutputStream();
ftDft.initTransfer(in, out);
// Host sends 10-byte fixed record "HELLO "
byte[] hostRecord = "HELLO ".getBytes(StandardCharsets.UTF_8);
ByteArrayOutputStream sf = new ByteArrayOutputStream();
sf.write(0x00); sf.write(hostRecord.length + 8);
sf.write(SF_TRANSFER_DATA);
sf.write(0x47); sf.write(0x04);
sf.write(TR_BEGIN_DATA);
int fieldLen = hostRecord.length + 5;
sf.write((fieldLen >> 8) & 0xFF); sf.write(fieldLen & 0xFF);
sf.write(hostRecord);
byte[] pkt = sf.toByteArray();
pkt[0] = (byte) ((pkt.length >> 8) & 0xFF);
pkt[1] = (byte) (pkt.length & 0xFF);
ftDft.processStructuredField(pkt, 0, pkt.length);
// Verify output has "HELLO" stripped of trailing spaces plus local line separator
String result = new String(out.toByteArray(), StandardCharsets.UTF_8);
assertEquals("HELLO" + System.lineSeparator(), result);
}
@Test
public void testRecfmVRdwUploadAndDownload() throws Exception {
// 1. Upload RECFM V
FTConfig sendConfig = new FTConfig();
sendConfig.setDirection(FTConfig.Direction.SEND);
sendConfig.setTransferMode(FTConfig.TransferMode.ASCII);
sendConfig.setRecfm(FTConfig.RecordFormat.VARIABLE);
listener.config = sendConfig;
String testLine = "VARIABLE RECORD TEST";
ByteArrayInputStream in = new ByteArrayInputStream((testLine + "\n").getBytes(StandardCharsets.UTF_8));
ByteArrayOutputStream out = new ByteArrayOutputStream();
ftDft.initTransfer(in, out);
byte[] getSf = { 0x00, 0x05, (byte) 0xD0, 0x46, 0x11 };
ftDft.processStructuredField(getSf, 0, getSf.length);
assertEquals(1, input.sentStructuredFields.size());
byte[] reply = input.sentStructuredFields.get(0);
int headerLen = 17;
// Verify 4-byte RDW header: length should be testLine.length() + 4
int rdwLen = ((reply[headerLen] & 0xFF) << 8) | (reply[headerLen + 1] & 0xFF);
assertEquals(testLine.length() + 4, rdwLen);
assertEquals(0, reply[headerLen + 2]);
assertEquals(0, reply[headerLen + 3]);
String payload = new String(reply, headerLen + 4, testLine.length(), StandardCharsets.UTF_8);
assertEquals(testLine, payload);
// 2. Download RECFM V
FTConfig recvConfig = new FTConfig();
recvConfig.setDirection(FTConfig.Direction.RECEIVE);
recvConfig.setTransferMode(FTConfig.TransferMode.ASCII);
recvConfig.setRecfm(FTConfig.RecordFormat.VARIABLE);
listener.config = recvConfig;
ByteArrayOutputStream downOut = new ByteArrayOutputStream();
ftDft.initTransfer(new ByteArrayInputStream(new byte[0]), downOut);
// Host sends RDW header + "TEST V DATA"
byte[] vData = "TEST V DATA".getBytes(StandardCharsets.UTF_8);
int vRdwLen = vData.length + 4;
ByteArrayOutputStream sfDown = new ByteArrayOutputStream();
sfDown.write(0x00); sfDown.write(0x00);
sfDown.write(SF_TRANSFER_DATA);
sfDown.write(0x47); sfDown.write(0x04);
sfDown.write(TR_BEGIN_DATA);
int fLen = vRdwLen + 5;
sfDown.write((fLen >> 8) & 0xFF); sfDown.write(fLen & 0xFF);
sfDown.write((vRdwLen >> 8) & 0xFF); sfDown.write(vRdwLen & 0xFF);
sfDown.write(0x00); sfDown.write(0x00);
sfDown.write(vData);
byte[] downPkt = sfDown.toByteArray();
downPkt[0] = (byte) ((downPkt.length >> 8) & 0xFF);
downPkt[1] = (byte) (downPkt.length & 0xFF);
ftDft.processStructuredField(downPkt, 0, downPkt.length);
String downResult = new String(downOut.toByteArray(), StandardCharsets.UTF_8);
assertEquals("TEST V DATA" + System.lineSeparator(), downResult);
}
@Test
public void testDbcsProgramSelectionAndOptions() {
FTConfig cfgDbcs = new FTConfig();
cfgDbcs.setCodePage("930"); // Japanese DBCS
assertEquals("APVUFILE", cfgDbcs.getProgramName());
FTConfig cfgSbcs = new FTConfig();
cfgSbcs.setCodePage("037"); // US English SBCS
assertEquals("IND$FILE", cfgSbcs.getProgramName());
// Option NEW and NOSO parsing
FTConfig optionsCfg = new FTConfig();
optionsCfg.setHostType(FTConfig.HostType.TSO);
optionsCfg.setDirection(FTConfig.Direction.SEND);
optionsCfg.setHostFilename("'TEST.FILE'");
optionsCfg.parseOptions("NEW NOSO APVUFILE");
assertTrue(optionsCfg.isOptionNew());
assertTrue(optionsCfg.isOptionNoso());
assertEquals("APVUFILE", optionsCfg.getProgramName());
String cmd = optionsCfg.buildCommand();
assertTrue(cmd.startsWith("APVUFILE PUT 'TEST.FILE'"));
assertTrue(cmd.contains("NEW"));
assertTrue(cmd.contains("NOSO"));
}
@Test
public void testCutErrorScreenScanning() {
FTCut cut = new FTCut(screen, input, translator, new FTCut.FTCutListener() {
@Override public void onCutRunning() {}
@Override public void onTransferComplete(String err) {}
@Override public void onTransferAborted(String err) {}
@Override public void onBytesTransferred(long b) {}
@Override public FTConstants.FTState getCurrentState() { return FTConstants.FTState.AWAIT_ACK; }
@Override public void setState(FTConstants.FTState s) {}
@Override public FTConfig getConfig() { return new FTConfig(); }
@Override public File getLocalFile() { return null; }
});
// Write an error line on row 2 of screen
String errLine = "IKJ56700I DATASET 'NONEXIST.FILE' NOT FOUND";
int startPos = 80; // row 2, col 1
for (int i = 0; i < errLine.length(); i++) {
screen.setCell(startPos + i, translator.unicodeToEbcdic(errLine.charAt(i)));
}
String detected = cut.scanScreenForErrors();
assertNotNull(detected);
assertTrue(detected.contains("IKJ56700I"));
assertTrue(detected.contains("NOT FOUND"));
}
}
@@ -0,0 +1,303 @@
package haus.nightmare.lib3270j.graphics;
import haus.nightmare.lib3270j.charset.EbcdicTranslator;
import haus.nightmare.lib3270j.input.InputProcessor;
import haus.nightmare.lib3270j.protocol.DS3270Constants;
import haus.nightmare.lib3270j.screen.ScreenBuffer;
import haus.nightmare.lib3270j.TerminalModel;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayOutputStream;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.*;
/**
* Targeted verification tests for Phase 17:
* - Multi-polygon area fills with boundary enforcement & winding/even-odd rules
* - GFARC/GCFARC full arc & ellipse generation with HODEllipse Bresenham tables
* - GARC/GCARC 3-point circular arc interpolation
* - FilletPts polynomial interpolation (HoD getFilletPoints) and area accumulation
* - GRLINE/GCRLIN relative lines with signed 8-bit deltas
* - GSAP (P/Q/R/S) aspect ratio and coordinate transformations
* - GBIMG image multiplier (imult) support
* - 56-byte Graphic Input structured field layout and sendGraphicMouseAid dispatch
*/
public class Phase17GocaGraphicsParityTest {
@Test
public void testMultiPolygonAreaFillWithBoundaryAndFillRules() {
GraphicsPlane plane = new GraphicsPlane(400, 300);
GocaDecoder decoder = new GocaDecoder(plane);
// 1. Begin Area with boundary flag set (0x40) and Winding rule (0x20)
ByteArrayOutputStream out = new ByteArrayOutputStream();
out.write(GocaConstants.G_GSCOL); out.write(0x02); // Red
out.write(GocaConstants.G_GSLW); out.write(0x02); // Line width 2
out.write(GocaConstants.G_GBAR); out.write(0x40 | 0x20); // flags: drawBoundary=true, winding=true
// First polygon: Triangle
out.write(GocaConstants.G_GLINE); out.write(12);
out.write(0x00); out.write(10); out.write(0x00); out.write(10);
out.write(0x00); out.write(50); out.write(0x00); out.write(10);
out.write(0x00); out.write(30); out.write(0x00); out.write(50);
// End Area
out.write(GocaConstants.G_GEAR);
byte[] stream = out.toByteArray();
decoder.decodeGoca(stream, 0, stream.length);
assertTrue(plane.hasContent(), "GraphicsPlane must have content after area fill");
assertTrue(decoder.hasActiveGraphics(), "Decoder must report active graphics when plane has content");
}
@Test
public void testHODEllipseAndFullArcGeneration() {
GraphicsPlane plane = new GraphicsPlane(400, 300);
GocaDecoder decoder = new GocaDecoder(plane);
// Set GSAP aspect ratio / semi-axes: P=20, Q=1, R=10, S=1
ByteArrayOutputStream out = new ByteArrayOutputStream();
out.write(GocaConstants.G_GSAP); out.write(8);
out.write(0x00); out.write(20); // P
out.write(0x00); out.write(1); // Q
out.write(0x00); out.write(10); // R
out.write(0x00); out.write(1); // S
// Full Arc Absolute (0xC7): Center=(100, 100), Multiplier=1.0 (1, 0)
out.write(GocaConstants.G_GFARC); out.write(6);
out.write(0x00); out.write(100); // centerX
out.write(0x00); out.write(100); // centerY
out.write(0x01); out.write(0x00); // Multiplier 1.00
byte[] stream = out.toByteArray();
decoder.decodeGoca(stream, 0, stream.length);
assertEquals(20, decoder.getArcParamP());
assertEquals(1, decoder.getArcParamQ());
assertEquals(10, decoder.getArcParamR());
assertEquals(1, decoder.getArcParamS());
assertTrue(plane.hasContent(), "Full arc must render on graphics plane");
// Verify direct HODEllipse Bresenham generation
int[] ptsX = new int[256];
int[] ptsY = new int[256];
int nPts = HODEllipse.getHODEllipse(20.0, 1.0, 10.0, 1.0, ptsX, ptsY, 50, 50);
assertTrue(nPts > 8, "HODEllipse must generate valid polygon vertices");
assertEquals(ptsX[0], ptsX[nPts - 1], "HODEllipse polygon must close to initial vertex");
assertEquals(ptsY[0], ptsY[nPts - 1], "HODEllipse polygon must close to initial vertex");
}
@Test
public void testThreePointCircularArcInterpolation() {
GraphicsPlane plane = new GraphicsPlane(400, 300);
GocaDecoder decoder = new GocaDecoder(plane);
// Current Position Arc (GCARC 0x86):
// Start at (0, 0), intermediate at (50, 50), end at (100, 0) -> upper semicircle
ByteArrayOutputStream out = new ByteArrayOutputStream();
out.write(GocaConstants.G_GSCP); out.write(4);
out.write(0x00); out.write(0);
out.write(0x00); out.write(0);
out.write(GocaConstants.G_GCARC); out.write(8);
out.write(0x00); out.write(50); // intermediate X
out.write(0x00); out.write(50); // intermediate Y
out.write(0x00); out.write(100); // end X
out.write(0x00); out.write(0); // end Y
byte[] stream = out.toByteArray();
decoder.decodeGoca(stream, 0, stream.length);
assertTrue(plane.hasContent(), "3-point circular arc must render on graphics plane");
assertEquals(100, decoder.getCurX(), "Terminal X must update to arc end point");
assertEquals(0, decoder.getCurY(), "Terminal Y must update to arc end point");
}
@Test
public void testFilletPolynomialInterpolationAndAreaAccumulation() {
GraphicsPlane plane = new GraphicsPlane(400, 300);
GocaDecoder decoder = new GocaDecoder(plane);
// Direct test of FilletPts polynomial interpolation
FilletPts fillet = new FilletPts();
int[] ctrlX = new int[] { 10, 50, 90 };
int[] ctrlY = new int[] { 10, 80, 10 };
int req = fillet.getPointsRequired(3);
int[] outX = new int[req];
int[] outY = new int[req];
int nPts = fillet.getFilletPoints(ctrlX, ctrlY, 3, outX, outY);
assertTrue(nPts >= 3, "FilletPts must interpolate curve points across control vertices");
// GFLT (0xC5 Fillet Absolute) inside an Area
ByteArrayOutputStream out = new ByteArrayOutputStream();
out.write(GocaConstants.G_GBAR); out.write(0x40); // drawBoundary=true
out.write(GocaConstants.G_GFLT); out.write(12);
out.write(0x00); out.write(10); out.write(0x00); out.write(10);
out.write(0x00); out.write(50); out.write(0x00); out.write(80);
out.write(0x00); out.write(90); out.write(0x00); out.write(10);
out.write(GocaConstants.G_GEAR);
byte[] stream = out.toByteArray();
decoder.decodeGoca(stream, 0, stream.length);
assertTrue(plane.hasContent(), "Area with fillet must render on graphics plane");
assertEquals(90, decoder.getCurX(), "Current X must update to last fillet vertex");
assertEquals(10, decoder.getCurY(), "Current Y must update to last fillet vertex");
}
@Test
public void testRelativeLinesWithSignedDeltas() {
GraphicsPlane plane = new GraphicsPlane(400, 300);
GocaDecoder decoder = new GocaDecoder(plane);
// GRLINE (0xE1): Start at (100, 100), dx=-20, dy=+30, dx=+40, dy=-15
ByteArrayOutputStream out = new ByteArrayOutputStream();
out.write(GocaConstants.G_GRLINE); out.write(8);
out.write(0x00); out.write(100); // startX
out.write(0x00); out.write(100); // startY
out.write((byte) -20); // dx1 = -20
out.write((byte) 30); // dy1 = +30
out.write((byte) 40); // dx2 = +40
out.write((byte) -15); // dy2 = -15
byte[] stream = out.toByteArray();
decoder.decodeGoca(stream, 0, stream.length);
assertTrue(plane.hasContent());
// 100 - 20 + 40 = 120
assertEquals(120, decoder.getCurX(), "Current X should be 120");
// 100 + 30 - 15 = 115
assertEquals(115, decoder.getCurY(), "Current Y should be 115");
}
@Test
public void testGsapAspectRatioAndCoordinateTransform() {
GddmCoordinateTransform transform = new GddmCoordinateTransform(400, 300);
transform.setAspectRatio(2, 1, 3, 1);
assertEquals(2.0, transform.getScaleX(), 0.001);
assertEquals(3.0, transform.getScaleY(), 0.001);
// Coordinate inversion tests
int gocaY = 50;
int screenY = transform.mapGocaYToScreen(gocaY);
int invertedGocaY = transform.mapScreenYToGoca(screenY);
assertEquals(gocaY, invertedGocaY, "Inversion round-trip must preserve GOCA Y");
transform.resetAspectRatio();
assertEquals(1.0, transform.getScaleX(), 0.001);
assertEquals(1.0, transform.getScaleY(), 0.001);
}
@Test
public void testImageMultiplierSupport() {
GraphicsPlane plane = new GraphicsPlane(400, 300);
GocaDecoder decoder = new GocaDecoder(plane);
// GBIMG (0xD1): origin=(10, 10), imult=2, w=8, h=8, 1 bpp uncompressed
ByteArrayOutputStream out = new ByteArrayOutputStream();
out.write(GocaConstants.G_GBIMG); out.write(10);
out.write(0x00); out.write(10); // X
out.write(0x00); out.write(10); // Y
out.write(0x00); out.write(2); // imult = 2
out.write(0x00); out.write(8); // w = 8
out.write(0x00); out.write(8); // h = 8
// GIMD (0x92): 8 bytes (8 rows of 8 bits each)
out.write(GocaConstants.G_GIMD); out.write(8);
for (int i = 0; i < 8; i++) {
out.write((byte) 0xFF);
}
// GEIMG (0x93)
out.write(GocaConstants.G_GEIMG);
byte[] stream = out.toByteArray();
decoder.decodeGoca(stream, 0, stream.length);
assertEquals(2, decoder.getImageMultiplier(), "Image multiplier must be parsed as 2");
assertTrue(plane.hasContent(), "Image must render on graphics plane");
}
@Test
public void testGraphicInputStructuredField56BytesLayout() {
int gocaX = 1234;
int gocaY = -5678;
int row = 10;
int col = 20;
// Button 1 (Pick), Shift=true, Ctrl=false
byte[] sf = GraphicInputBuilder.buildGraphicInput(gocaX, gocaY, row, col, 1, true, true, false);
assertEquals(56, sf.length, "Structured field length must be 56 bytes");
// Bytes 0-1: 0x0034 (52 decimal)
assertEquals(0x00, sf[0]);
assertEquals(0x34, sf[1]);
// Bytes 16-19: Class descriptor (0x23, 0x00, 0x23, 0x00)
assertEquals(0x23, sf[16]);
assertEquals(0x00, sf[17]);
assertEquals(0x23, sf[18]);
assertEquals(0x00, sf[19]);
// Bytes 24-25: 16-bit signed GOCA X
short x = (short) (((sf[24] & 0xFF) << 8) | (sf[25] & 0xFF));
assertEquals(gocaX, x);
// Bytes 26-27: 16-bit signed GOCA Y
short y = (short) (((sf[26] & 0xFF) << 8) | (sf[27] & 0xFF));
assertEquals(gocaY, y);
// Bytes 28-31: Fixed mouse trigger class (0x00000004)
assertEquals(0x00, sf[28]);
assertEquals(0x00, sf[29]);
assertEquals(0x00, sf[30]);
assertEquals(0x04, sf[31]);
// Bytes 32-33: Fixed correlation class (0x0004)
assertEquals(0x00, sf[32]);
assertEquals(0x04, sf[33]);
// Byte 34: Shift flag 0x80
assertEquals((byte) 0x80, sf[34]);
// Byte 35: Button 1 (0x01)
assertEquals(0x01, sf[35]);
// Button 2 (Action), Shift=false, Ctrl=true
byte[] sf2 = GraphicInputBuilder.buildGraphicInput(gocaX, gocaY, row, col, 2, true, false, true);
assertEquals((byte) 0x40, sf2[34]);
assertEquals(0x02, sf2[35]);
}
@Test
public void testSendGraphicMouseAidInteractiveDispatch() {
EbcdicTranslator translator = new EbcdicTranslator();
ScreenBuffer screen = new ScreenBuffer(TerminalModel.IBM_3279_2, translator);
GraphicsPlane plane = new GraphicsPlane(400, 300);
GocaDecoder decoder = new GocaDecoder(plane);
AtomicReference<byte[]> sentData = new AtomicReference<>();
InputProcessor inputProcessor = new InputProcessor(screen, translator, null) {
@Override
protected void sendAidResponse(byte[] data) {
sentData.set(data);
}
};
inputProcessor.setGocaDecoder(decoder);
// When no graphics are active, regular RMA is sent
inputProcessor.sendGraphicMouseAid(DS3270Constants.AID_ENTER, 1, false, false);
assertNotNull(sentData.get());
assertNotEquals(DS3270Constants.AID_SF, sentData.get()[0], "Without active graphics, AID_SF must not be sent");
// Activate graphics cursor
sentData.set(null);
inputProcessor.setKeyboardLocked(false);
decoder.attachGraphicCursor(100, 200);
assertTrue(decoder.hasActiveGraphics());
inputProcessor.sendGraphicMouseAid(100, 200, DS3270Constants.AID_ENTER, 1, false, false);
byte[] out = sentData.get();
assertNotNull(out);
assertEquals(DS3270Constants.AID_SF, out[0] & 0xFF, "With active graphics, Structured Field AID (0x88) must be sent");
assertTrue(out.length >= 57, "Output must contain AID byte + 56-byte Graphic Input SF");
assertEquals(0x00, out[1]);
assertEquals(0x34, out[2]);
}
}
@@ -51,7 +51,7 @@ public class ProgramSymbolManagerTest {
ProgramSymbolSet.SymbolSlot slot = manager.getSymbol(0x40, 0x41);
assertNotNull(slot);
assertEquals(9, slot.getWidth());
assertEquals(12, slot.getHeight());
assertEquals(16, slot.getHeight());
assertFalse(slot.isTriplePlane());
// Verify pixel data (Row 0 Col 0 is 1)
@@ -64,7 +64,7 @@ public class ProgramSymbolManagerTest {
int bg = 0xFF000000; // Black
int[] rgb = slot.getRgbPixels(fg, bg);
assertNotNull(rgb);
assertEquals(9 * 12, rgb.length);
assertEquals(9 * 16, rgb.length);
assertEquals(fg, rgb[0]); // Row 0 Col 0 pixel should be foreground Green
}
@@ -164,4 +164,128 @@ public class ProgramSymbolManagerTest {
assertNotNull(slot);
assertEquals(1, slot.getPixelData()[0]);
}
@Test
public void testMultiSymbolLoadPsTriplePlane190Symbols() {
ProgramSymbolManager manager = new ProgramSymbolManager();
// Simulate bits image loader connect payload: 190 glyphs * 18 bytes = 3420 bytes
// Sent to a triple plane set (RWS 4, LCID 0x42) with colorPlane = 0 (no ext header)
byte[] payload = new byte[4 + 190 * 18];
payload[0] = 0x41; // Format 1, clearAll = true
payload[1] = 0x42; // LCID 0x42
payload[2] = 0x41; // Start codepoint 0x41
payload[3] = 0x04; // RWS 4 (Triple Plane)
for (int i = 0; i < 190; i++) {
// Set Row 0 Col 0 for each symbol
payload[4 + i * 18] = (byte) 0x80;
}
manager.loadps(payload);
ProgramSymbolSet set = manager.getSymbolSet(0x42);
assertNotNull(set);
// Verify ALL 190 glyphs (from 0x41 to 0xFE) were loaded, NOT just 63!
for (int cp = 0x41; cp <= 0xFE; cp++) {
ProgramSymbolSet.SymbolSlot slot = manager.getSymbol(0x42, cp);
assertNotNull(slot, "Glyph for codepoint 0x" + Integer.toHexString(cp) + " must not be null!");
assertEquals(9, slot.getWidth());
assertEquals(16, slot.getHeight());
assertTrue(slot.isTriplePlane());
assertEquals(7, slot.getPixelData()[0] & 0xFF, "Pixel (0,0) must have all 3 planes set (7)");
}
}
@Test
public void testColorPlaneCompositeMergingAndPalette() {
ProgramSymbolManager manager = new ProgramSymbolManager();
// 1. Send Plane 1 (Red) for codepoint 0x41
byte[] sf1 = new byte[4 + 6 + 18];
sf1[0] = (byte) 0x81; // Format 1, extHeader=true
sf1[1] = 0x42; // LCID 0x42
sf1[2] = 0x41; // Codepoint 0x41
sf1[3] = 0x04; // RWS 4
sf1[4] = 0x06; // Ext header len = 6
sf1[9] = 0x01; // Plane 1 = Red
sf1[10] = (byte) 0x80;// Row 0 Col 0 = bit 1
manager.loadps(sf1);
ProgramSymbolSet.SymbolSlot slot1 = manager.getSymbol(0x42, 0x41);
assertNotNull(slot1);
assertEquals(1, slot1.getPixelData()[0] & 0xFF);
// Plane 1 alone must be Red (GOCA_COLORS[2] = 0xFFF01818)
assertEquals(GocaConstants.GOCA_COLORS[2], slot1.getRgbPixels(0, 0)[0]);
// 2. Send Plane 2 (Green) for codepoint 0x41
byte[] sf2 = new byte[4 + 6 + 18];
sf2[0] = (byte) 0x81;
sf2[1] = 0x42;
sf2[2] = 0x41;
sf2[3] = 0x04;
sf2[4] = 0x06;
sf2[9] = 0x02; // Plane 2 = Green
sf2[10] = (byte) 0x80;// Row 0 Col 0 = bit 1
manager.loadps(sf2);
ProgramSymbolSet.SymbolSlot slot2 = manager.getSymbol(0x42, 0x41);
assertNotNull(slot2);
// Red (1) | Green (2) = 3 (Yellow)
assertEquals(3, slot2.getPixelData()[0] & 0xFF);
assertEquals(GocaConstants.GOCA_COLORS[6], slot2.getRgbPixels(0, 0)[0]);
// 3. Send Plane 4 (Blue) for codepoint 0x41
byte[] sf3 = new byte[4 + 6 + 18];
sf3[0] = (byte) 0x81;
sf3[1] = 0x42;
sf3[2] = 0x41;
sf3[3] = 0x04;
sf3[4] = 0x06;
sf3[9] = 0x04; // Plane 4 = Blue
sf3[10] = (byte) 0x80;// Row 0 Col 0 = bit 1
manager.loadps(sf3);
ProgramSymbolSet.SymbolSlot slot3 = manager.getSymbol(0x42, 0x41);
assertNotNull(slot3);
// Red (1) | Green (2) | Blue (4) = 7 (White)
assertEquals(7, slot3.getPixelData()[0] & 0xFF);
assertEquals(GocaConstants.GOCA_COLORS[7], slot3.getRgbPixels(0, 0)[0]);
}
@Test
public void testTransitionMonochromeToColor() {
ProgramSymbolManager manager = new ProgramSymbolManager();
// 1. Initial monochrome load (colorPlane = 0)
byte[] sfMono = new byte[4 + 18];
sfMono[0] = 0x01; // Format 1
sfMono[1] = 0x42;
sfMono[2] = 0x41;
sfMono[3] = 0x04; // RWS 4
sfMono[4] = (byte) 0x80; // Row 0 Col 0 set
manager.loadps(sfMono);
ProgramSymbolSet.SymbolSlot monoSlot = manager.getSymbol(0x42, 0x41);
assertEquals(7, monoSlot.getPixelData()[0] & 0xFF);
assertTrue(monoSlot.isAllPlanesLoaded());
// 2. Host now sends color plane 1 (Red only)
byte[] sfRed = new byte[4 + 6 + 18];
sfRed[0] = (byte) 0x81;
sfRed[1] = 0x42;
sfRed[2] = 0x41;
sfRed[3] = 0x04;
sfRed[4] = 0x06;
sfRed[9] = 0x01; // Plane 1 (Red)
sfRed[10] = (byte) 0x80;
manager.loadps(sfRed);
ProgramSymbolSet.SymbolSlot colorSlot = manager.getSymbol(0x42, 0x41);
// Previous monochrome bits (7) must NOT bleed in! Only Red (1) should be present!
assertEquals(1, colorSlot.getPixelData()[0] & 0xFF);
assertEquals(GocaConstants.GOCA_COLORS[2], colorSlot.getRgbPixels(0, 0)[0]);
assertFalse(colorSlot.isAllPlanesLoaded());
}
}
@@ -218,6 +218,32 @@ public class InputProcessorTest {
assertEquals(2, screen.getCursorAddress());
}
@Test
public void testProtectedFieldSpaceDesignatorNoLightPenSelection() {
screen.erase(false);
// Field attribute at pos 0: PROTECTED, intensified selectable (0x28) as found in ISPF screen titles
screen.setCellFA(0, (byte) (FA_PRINTABLE | FA_PROTECT | FA_INT_HIGH_SEL));
// Designator character at pos 1: space
screen.getCell(1).ec = 0x40;
screen.getCell(1).ucs4 = ' ';
screen.getCell(2).ec = (byte) 0xC1;
screen.getCell(2).ucs4 = 'A';
java.util.concurrent.atomic.AtomicInteger sentAid = new java.util.concurrent.atomic.AtomicInteger(-1);
InputProcessor input = new InputProcessor(screen, translator, null) {
@Override
public void sendAid(int aidCode) {
sentAid.set(aidCode);
}
};
// Per IBM GA23-0059, clicking/light-pen on protected field with space/null must have no effect
boolean result = input.lightPenSelect(2);
assertFalse(result, "Protected field with space designator must not trigger light-pen selection");
assertEquals(-1, sentAid.get(), "Must not send AID_SELECT");
assertFalse((screen.getCell(0).fa & FA_MODIFY) != 0, "Must not set FA_MODIFY");
}
@Test
public void testLightPenSelectEnterImmediateAmpersand() {
screen.erase(false);