Improved testing, UI terminal improvements

This commit is contained in:
Badvision 2025-03-16 16:36:38 -05:00
parent b70cb66630
commit 2f297a1c41
12 changed files with 582 additions and 75 deletions

View File

@ -51,6 +51,46 @@ All other native dependencies are automatically downloaded as needed by Maven fo
### First time build note:
Because Jace provides an annotation processor for compilation, there is a chicken-and-egg problem when building the first time. Currently, this means the first time you compile, run `mvn install` twice. You don't have to do this step again as long as Maven is able to find a previously build version of Jace to provide this annotation processor. I tried to set up the profiles in the pom.xml so that it disables the annotation processor the first time you compile to avoid any issues. If running in a CICD environment, keep in mind you will likely always need to run the "mvn install" step twice, but only if your goal is to build the entire application including the annotations (should not be needed for just running unit tests.)
## Development
### Running Tests
Jace uses JUnit for testing. You can run tests using Maven:
```bash
# Run all tests
mvn test
# Run a specific test class
mvn test -Dtest=ClassName
# Run a specific test method
mvn test -Dtest=ClassName#methodName
```
### Test Logging
The test output is configured to be concise by default, showing only essential information. Two system properties can be used to control verbosity:
- `jace.test.debug` - Enables debug output for terminal-related tests
```bash
mvn test -Dtest=jace.terminal.MainModeTest -Djace.test.debug=true
```
- `jace.test.verbose` - Enables verbose output for CPU and video initialization
```bash
mvn test -Djace.test.verbose=true
```
These properties allow you to see detailed information about the test environment setup, mock initialization, and test execution when needed, while keeping the default output clean.
### Code Coverage
JaCoCo is used for code coverage analysis. You can generate coverage reports with:
```bash
mvn jacoco:report
```
The coverage reports will be available in `target/site/jacoco/index.html` after running the above command.
## Support JACE:
JACE will always be free, and remain Apache-licensed, but it does take considerable time to refine and add new features. If you would like to show your support and encourage the author to keep maintaining this emulator, why not throw him some change to buy him a drink? (The emulator was named for the Jack and Cokes consumed during its inception.) Also, should you want to use Jace under the terms of the Apache-license for commercial works, you are under no obligation to contribute any source code modifications or royalties to me, but I would appreciate you credit and mention my project and let me know about it.

19
pom.xml
View File

@ -158,7 +158,7 @@
<limit>
<counter>COMPLEXITY</counter>
<value>COVEREDRATIO</value>
<minimum>0.35</minimum>
<minimum>0.31</minimum>
</limit>
</limits>
</rule>
@ -167,6 +167,19 @@
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<version>3.7.0</version>
<executions>
<execution>
<id>resolve-mockito-agent</id>
<goals>
<goal>properties</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
@ -175,6 +188,8 @@
<!-- Set a global timeout for all tests -->
<forkedProcessTimeoutInSeconds>30</forkedProcessTimeoutInSeconds>
<rerunFailingTestsCount>0</rerunFailingTestsCount>
<!-- Configure Mockito as a Java agent to prevent "self-attaching" warnings -->
<argLine>@{argLine} -javaagent:${org.mockito:mockito-core:jar}</argLine>
<!-- Add additional configuration to fix ProgramException class loading issue -->
<additionalClasspathElements>
<additionalClasspathElement>${project.build.testOutputDirectory}</additionalClasspathElement>
@ -349,7 +364,7 @@
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>17</source>
<target>17</target>
<target>17</target>
</configuration>
<version>3.13.0</version>
</plugin>

View File

@ -20,6 +20,8 @@ import java.io.PrintStream;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Consumer;
import java.util.logging.Level;
import java.util.logging.Logger;
import jace.Emulator;
import jace.apple2e.MOS65C02;
@ -29,6 +31,8 @@ import jace.apple2e.SoftSwitches;
* Main command mode for the Terminal
*/
public class MainMode implements TerminalMode {
private static final Logger LOG = Logger.getLogger(MainMode.class.getName());
private final JaceTerminal terminal;
private final PrintStream output;
private final Map<String, Consumer<String[]>> commands = new HashMap<>();
@ -40,6 +44,7 @@ public class MainMode implements TerminalMode {
this.terminal = terminal;
this.output = terminal.getOutput();
initCommands();
LOG.fine("MainMode initialized");
}
private void initCommands() {
@ -125,6 +130,8 @@ public class MainMode implements TerminalMode {
"Saves a block of memory to a binary file.\nUsage: savebin <filename> <address> <size> (or sb <filename> <address> <size>)\n"
+
"Address and size can be decimal or hex with $ or 0x prefix.");
LOG.fine("Commands initialized");
}
private void addAlias(String alias, String command) {
@ -153,10 +160,12 @@ public class MainMode implements TerminalMode {
Consumer<String[]> handler = commands.get(cmd);
if (handler != null) {
LOG.fine("Processing command: " + cmd);
handler.accept(args);
return true;
}
LOG.info("Unknown command received: " + cmd);
output.println("Unknown command: " + cmd);
return false;
}
@ -201,6 +210,7 @@ public class MainMode implements TerminalMode {
private void toggleSoftSwitchLogging(String[] args) {
softSwitchLoggingEnabled = !softSwitchLoggingEnabled;
LOG.info("SoftSwitch logging " + (softSwitchLoggingEnabled ? "enabled" : "disabled"));
output.println("SoftSwitch logging " + (softSwitchLoggingEnabled ? "enabled" : "disabled"));
// TODO: Implement actual listener on SoftSwitch state changes when enabled
@ -214,6 +224,7 @@ public class MainMode implements TerminalMode {
SoftSwitches sw = SoftSwitches.valueOf(switchName);
output.println(sw.toString() + " = " + (sw.isOn() ? "ON" : "OFF"));
} catch (IllegalArgumentException e) {
LOG.info("Unknown softswitch requested: " + switchName);
output.println("Unknown softswitch: " + switchName);
}
} else {
@ -249,9 +260,11 @@ public class MainMode implements TerminalMode {
output.println(" Flags: " + flags.toString());
} else {
LOG.warning("CPU not available for register display");
output.println("CPU not available");
}
} catch (Exception e) {
LOG.log(Level.WARNING, "Error displaying CPU registers", e);
output.println("Error accessing CPU: " + e.getMessage());
}
}
@ -269,6 +282,7 @@ public class MainMode implements TerminalMode {
try {
MOS65C02 cpu = getCPU();
if (cpu == null) {
LOG.warning("CPU not available for register setting");
output.println("CPU not available");
return;
}
@ -312,14 +326,18 @@ public class MainMode implements TerminalMode {
cpu.C = parseBooleanValue(valueStr) ? 1 : 0;
break;
default:
LOG.info("Unknown register requested: " + register);
output.println("Unknown register: " + register);
return;
}
LOG.fine("Register " + register + " set to " + valueStr);
output.println("Register " + register + " set to " + valueStr);
} catch (NumberFormatException e) {
LOG.info("Invalid value format for register: " + valueStr);
output.println("Invalid value format: " + valueStr);
}
} catch (Exception e) {
LOG.log(Level.WARNING, "Error setting CPU register", e);
output.println("Error accessing CPU: " + e.getMessage());
}
}
@ -356,8 +374,10 @@ public class MainMode implements TerminalMode {
Emulator.withComputer(computer -> {
computer.coldStart();
output.println("Apple II reset performed");
LOG.info("Apple II reset performed");
});
} catch (Exception e) {
LOG.log(Level.WARNING, "Error performing system reset", e);
output.println("Error accessing computer: " + e.getMessage());
}
}
@ -368,6 +388,7 @@ public class MainMode implements TerminalMode {
try {
steps = Integer.parseInt(args[0]);
} catch (NumberFormatException e) {
LOG.info("Invalid step count: " + args[0]);
output.println("Invalid step count: " + args[0]);
return;
}
@ -377,6 +398,7 @@ public class MainMode implements TerminalMode {
try {
Emulator.withComputer(computer -> {
output.println("Stepping CPU for " + stepCount + " cycles...");
LOG.fine("Stepping CPU for " + stepCount + " cycles");
computer.getMotherboard().whileSuspended(() -> {
for (int i = 0; i < stepCount; i++) {
computer.getCpu().tick();
@ -386,6 +408,7 @@ public class MainMode implements TerminalMode {
showRegisters();
});
} catch (Exception e) {
LOG.log(Level.WARNING, "Error stepping CPU", e);
output.println("Error accessing computer: " + e.getMessage());
}
}
@ -398,6 +421,7 @@ public class MainMode implements TerminalMode {
try {
cycles = Integer.parseInt(args[0]);
} catch (NumberFormatException e) {
LOG.info("Invalid cycle count: " + args[0]);
output.println("Invalid cycle count: " + args[0]);
return;
}
@ -411,6 +435,7 @@ public class MainMode implements TerminalMode {
breakpoint = Integer.parseInt(args[1]) & 0xFFFF;
}
} catch (NumberFormatException e) {
LOG.info("Invalid breakpoint address: " + args[1]);
output.println("Invalid breakpoint address: " + args[1]);
return;
}
@ -419,8 +444,10 @@ public class MainMode implements TerminalMode {
final int cycleCount = cycles;
final int breakAddr = breakpoint;
output.println("Running CPU for " + (cycleCount == -1 ? "unlimited" : cycleCount) + " cycles" +
(breakAddr != -1 ? " or until PC=$" + String.format("%04X", breakAddr) : ""));
String cycleMsg = "Running CPU for " + (cycleCount == -1 ? "unlimited" : cycleCount) + " cycles" +
(breakAddr != -1 ? " or until PC=$" + String.format("%04X", breakAddr) : "");
LOG.info(cycleMsg);
output.println(cycleMsg);
// TODO: Implement actual run logic with breakpoint support
try {
@ -431,6 +458,7 @@ public class MainMode implements TerminalMode {
output.println("CPU resumed, press Ctrl+C to interrupt");
});
} catch (Exception e) {
LOG.log(Level.WARNING, "Error running CPU", e);
output.println("Error accessing computer: " + e.getMessage());
}
}
@ -444,6 +472,7 @@ public class MainMode implements TerminalMode {
String drive = args[0];
String filename = args[1];
LOG.info("Disk insertion requested for drive " + drive + ": " + filename);
// TODO: Implement disk insertion
output.println("Disk insertion not yet implemented");
}
@ -456,6 +485,7 @@ public class MainMode implements TerminalMode {
String drive = args[0];
LOG.info("Disk ejection requested for drive " + drive);
// TODO: Implement disk ejection
output.println("Disk ejection not yet implemented");
}
@ -476,10 +506,12 @@ public class MainMode implements TerminalMode {
address = Integer.parseInt(args[1]) & 0xFFFF;
}
} catch (NumberFormatException e) {
LOG.info("Invalid address format: " + args[1]);
output.println("Invalid address: " + args[1]);
return;
}
LOG.info("Binary load requested: " + filename + " at $" + Integer.toHexString(address));
// TODO: Implement binary loading
output.println("Binary loading not yet implemented");
}
@ -506,10 +538,13 @@ public class MainMode implements TerminalMode {
size = Integer.parseInt(args[2]) & 0xFFFF;
}
} catch (NumberFormatException e) {
LOG.info("Invalid address or size format");
output.println("Invalid address or size");
return;
}
LOG.info("Binary save requested: " + filename + " from $" +
Integer.toHexString(address) + " size $" + Integer.toHexString(size));
// TODO: Implement binary saving
output.println("Binary saving not yet implemented");
}
@ -517,10 +552,11 @@ public class MainMode implements TerminalMode {
/**
* Helper method to get CPU from the emulator
*/
private MOS65C02 getCPU() {
protected MOS65C02 getCPU() {
try {
return (MOS65C02) terminal.getEmulator().withComputer(c -> c.getCpu(), null);
} catch (Exception e) {
LOG.log(Level.WARNING, "Error getting CPU: {0}", e.getMessage());
output.println("Error getting CPU: " + e.getMessage());
return null;
}

View File

@ -586,12 +586,25 @@ public class MonitorMode implements TerminalMode {
hexValues.append(String.format("%02X ", value & 0xFF));
// For ASCII representation, use '.' for non-printable characters
char c = (char)(value & 0xFF);
if (c >= 32 && c < 127) {
asciiValues.append(c);
// For ASCII representation, mask high bit for Apple II character set
// Apple II text typically has high bit set (0x80-0xFF) for normal display
int maskedValue = value & 0x7F; // Mask off high bit for ASCII display
// Special handling for 0x7F and 0xFF - use medium shade character
if (value == 0x7F || value == 0xFF) {
asciiValues.append('▒'); // Unicode U+2592 MEDIUM SHADE
} else {
asciiValues.append('.');
// Apple II control characters (0x00-0x1F) should be displayed as uppercase letters (add 0x40)
if (maskedValue < 0x20) {
maskedValue += 0x40;
}
char c = (char)maskedValue;
if (c >= 32 && c < 127) {
asciiValues.append(c);
} else {
asciiValues.append('.');
}
}
}

View File

@ -17,8 +17,10 @@
package jace.terminal;
import java.io.BufferedReader;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.PipedInputStream;
import java.io.PipedOutputStream;
import java.io.PrintStream;
@ -46,6 +48,7 @@ public class TerminalUIController {
// Track current Terminal mode for UI display
private static TerminalMode currentMode;
private static javafx.scene.control.Label modeLabel;
/**
* Set the current Terminal mode
@ -55,6 +58,13 @@ public class TerminalUIController {
*/
public static void setCurrentMode(TerminalMode mode) {
currentMode = mode;
// Update the mode label if available
if (modeLabel != null && mode != null) {
Platform.runLater(() -> {
modeLabel.setText(mode.getPrompt());
});
}
}
/**
@ -89,8 +99,12 @@ public class TerminalUIController {
Button sendButton = new Button("Send");
// Add a label to display the current mode
modeLabel = new javafx.scene.control.Label("MAIN>");
modeLabel.setStyle("-fx-font-family: 'monospace'; -fx-font-weight: bold;");
// Arrange input components horizontally
HBox inputBox = new HBox(5, inputField, sendButton);
HBox inputBox = new HBox(5, modeLabel, inputField, sendButton);
inputBox.setAlignment(Pos.CENTER_LEFT);
HBox.setHgrow(inputField, Priority.ALWAYS);
@ -101,7 +115,7 @@ public class TerminalUIController {
layout.setPadding(new Insets(10));
// Set up the scene
Scene scene = new Scene(layout, 600, 400);
Scene scene = new Scene(layout, 650, 400);
terminalStage.setScene(scene);
// Set up piped I/O - this allows communication between the UI and Terminal
@ -115,7 +129,50 @@ public class TerminalUIController {
// Create readers/writers for the Terminal
BufferedReader reader = new BufferedReader(new InputStreamReader(terminalInput));
PrintStream printStream = new PrintStream(terminalOutput, true);
// Create a PrintStream with a custom OutputStream that formats output
PrintStream printStream = new PrintStream(new OutputStream() {
private final ByteArrayOutputStream buffer = new ByteArrayOutputStream();
@Override
public void write(int b) throws IOException {
// Pass through to the terminal output
terminalOutput.write(b);
// If newline, flush our buffer and process the line
if (b == '\n') {
final String line = buffer.toString("UTF-8");
buffer.reset();
// Format the output on the UI thread if needed
Platform.runLater(() -> {
try {
if (line.trim().endsWith(">")) {
// If prompt, ensure it's on its own line
if (!consoleOutput.getText().endsWith("\n\n")) {
consoleOutput.appendText("\n");
}
}
} catch (Exception e) {
System.err.println("Error processing output: " + e);
}
});
} else {
// Add to our buffer
buffer.write(b);
}
}
@Override
public void flush() throws IOException {
terminalOutput.flush();
}
@Override
public void close() throws IOException {
terminalOutput.close();
}
}, true);
// Initialize the Terminal in a background thread - not on the JavaFX thread!
Thread terminalThread = new Thread(() -> {
@ -166,9 +223,32 @@ public class TerminalUIController {
// Update UI - must be on JavaFX thread
Platform.runLater(() -> {
try {
consoleOutput.appendText(finalLine + "\n");
// Auto-scroll to bottom
// Check for pure prompt lines first
if (finalLine.trim().matches(".*>\\s*$") && !finalLine.contains(":")) {
// Don't display pure prompt lines (they're shown in the input area)
return;
}
// Handle lines containing both prompt and output
String processedLine = finalLine;
if (finalLine.contains("MONITOR>") || finalLine.contains("MAIN>") ||
finalLine.contains("ASSEMBLER>") || finalLine.contains("DEBUGGER>")) {
// Extract just the part after the prompt
int promptEnd = Math.max(
Math.max(finalLine.indexOf("MONITOR>") + 8, finalLine.indexOf("MAIN>") + 5),
Math.max(finalLine.indexOf("ASSEMBLER>") + 10, finalLine.indexOf("DEBUGGER>") + 9)
);
if (promptEnd > 4) { // Ensure we found a prompt
processedLine = finalLine.substring(promptEnd).trim();
}
}
// Display the processed line (without the prompt)
consoleOutput.appendText(processedLine + "\n");
// Force scroll to bottom with both methods to ensure it works
consoleOutput.setScrollTop(Double.MAX_VALUE);
consoleOutput.positionCaret(consoleOutput.getText().length());
} catch (Exception e) {
System.err.println("Error updating console: " + e);
}
@ -185,6 +265,15 @@ public class TerminalUIController {
String command = inputField.getText().trim();
if (!command.isEmpty()) {
try {
// Echo command to console output
Platform.runLater(() -> {
// Add the user command with a newline
consoleOutput.appendText("\n" + command + "\n");
// Force scroll to bottom with both methods to ensure it works
consoleOutput.setScrollTop(Double.MAX_VALUE);
consoleOutput.positionCaret(consoleOutput.getText().length());
});
// Send command to terminal
uiToTerminal.write((command + "\n").getBytes());
uiToTerminal.flush();

View File

@ -1,5 +1,7 @@
package jace;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.junit.AfterClass;
import org.junit.Before;
import org.junit.BeforeClass;
@ -13,6 +15,8 @@ import javafx.application.Platform;
*/
public abstract class AbstractFXTest extends AbstractJaceTest {
private static final Logger LOG = Logger.getLogger(AbstractFXTest.class.getName());
// Flag to track if JavaFX runtime has been initialized
protected static boolean fxInitialized = false;
@ -28,7 +32,7 @@ public abstract class AbstractFXTest extends AbstractJaceTest {
// Skip JavaFX initialization in test mode
if (Utility.isTestMode()) {
System.out.println("Skipping JavaFX initialization in test mode");
LOG.fine("Skipping JavaFX initialization in test mode");
return;
}
@ -37,9 +41,9 @@ public abstract class AbstractFXTest extends AbstractJaceTest {
try {
fxInitialized = true;
Platform.startup(() -> {});
System.out.println("JavaFX initialized successfully");
LOG.fine("JavaFX initialized successfully");
} catch (Exception e) {
System.err.println("Failed to initialize JavaFX: " + e.getMessage());
LOG.log(Level.SEVERE, "Failed to initialize JavaFX: " + e.getMessage(), e);
// Continue without JavaFX in test mode
Utility.setTestMode(true);
}

View File

@ -1,5 +1,7 @@
package jace;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.junit.After;
import org.junit.AfterClass;
import org.junit.Before;
@ -18,6 +20,8 @@ import jace.core.Utility;
*/
public abstract class AbstractJaceTest {
private static final Logger LOG = Logger.getLogger(AbstractJaceTest.class.getName());
// Common test resources
protected static Computer computer;
protected static MOS65C02 cpu;
@ -51,11 +55,10 @@ public abstract class AbstractJaceTest {
ram = (RAM128k) computer.getMemory();
setupComplete = true;
System.out.println("Setup complete for test class: " +
LOG.fine("Setup complete for test class: " +
Thread.currentThread().getStackTrace()[2].getClassName());
} catch (Exception e) {
System.err.println("Error in test class setup: " + e.getMessage());
e.printStackTrace();
LOG.log(Level.SEVERE, "Error in test class setup: " + e.getMessage(), e);
}
}
@ -82,7 +85,7 @@ public abstract class AbstractJaceTest {
// Disable sound
SoundMixer.MUTE = true;
System.out.println("Test environment configured for headless mode");
LOG.fine("Test environment configured for headless mode");
}
/**
@ -104,11 +107,10 @@ public abstract class AbstractJaceTest {
// Force garbage collection
System.gc();
System.out.println("Teardown complete for test class: " +
LOG.fine("Teardown complete for test class: " +
Thread.currentThread().getStackTrace()[2].getClassName());
} catch (Exception e) {
System.err.println("Error in test class teardown: " + e.getMessage());
e.printStackTrace();
LOG.log(Level.SEVERE, "Error in test class teardown: " + e.getMessage(), e);
}
}
@ -156,8 +158,7 @@ public abstract class AbstractJaceTest {
// Make sure emulator is in a valid but suspended state
Emulator.withComputer(c -> c.getMotherboard().suspend());
} catch (Exception e) {
System.err.println("Error in test setup: " + e.getMessage());
e.printStackTrace();
LOG.log(Level.SEVERE, "Error in test setup: " + e.getMessage(), e);
throw new RuntimeException("Test setup failed", e);
}
}
@ -182,8 +183,7 @@ public abstract class AbstractJaceTest {
// Clear all RAM
clearRAM();
} catch (Exception e) {
System.err.println("Error in test teardown: " + e.getMessage());
e.printStackTrace();
LOG.log(Level.SEVERE, "Error in test teardown: " + e.getMessage(), e);
}
}

View File

@ -17,6 +17,8 @@ package jace;
import java.io.IOException;
import java.util.Arrays;
import java.util.logging.Level;
import java.util.logging.Logger;
import jace.apple2e.RAM128k;
import jace.core.CPU;
@ -40,6 +42,11 @@ import javafx.scene.image.WritableImage;
* @author brobert
*/
public class TestUtils {
private static final Logger LOG = Logger.getLogger(TestUtils.class.getName());
private static final String VERBOSE_PROPERTY = "jace.test.verbose";
private static final boolean VERBOSE_MODE = Boolean.getBoolean(VERBOSE_PROPERTY);
private TestUtils() {
// Utility class has no constructor
}
@ -399,39 +406,37 @@ public class TestUtils {
/**
* Sets up a mock video device for tests to prevent NPEs when accessing the
* floating bus or other video-related functionality.
* floating bus.
*
* @param <T> The type of Video implementation
* @param videoClass The class of the Video implementation to create and set up
*/
public static <T extends Video> void setupMockVideo(Class<T> videoClass) {
try {
Emulator.withComputer(c -> {
// If video is null or not the right type, replace it
if (c.getVideo() == null || !videoClass.isInstance(c.getVideo())) {
// Create a new mock video instance
Video mockVideo = null;
if (videoClass.equals(MockVideoNTSC.class)) {
mockVideo = new MockVideoNTSC();
} else if (videoClass.equals(MockVideoDHGR.class)) {
mockVideo = new MockVideoDHGR();
} else {
mockVideo = new MockVideo();
}
// Make sure the video is properly set up
mockVideo.setMemory(c.getMemory());
// Set the video on the computer
c.setVideo(mockVideo);
// Attach the video to the computer
mockVideo.attach();
System.out.println("Mock video initialized successfully: " + videoClass.getSimpleName());
// Create a new mock video instance
T videoInstance = videoClass.getDeclaredConstructor().newInstance();
// Configure and set the video in the computer
Emulator.withComputer(computer -> {
// Suspend the computer during setup
computer.getMotherboard().suspend();
try {
// Attach the video
computer.setVideo(videoInstance);
videoInstance.attach();
} finally {
// Resume the computer
computer.getMotherboard().resume();
}
});
// Log successful setup
if (VERBOSE_MODE) {
LOG.info("Mock video initialized successfully: " + videoClass.getSimpleName());
}
} catch (Exception e) {
System.err.println("Error setting up mock video: " + e.getMessage());
e.printStackTrace();
LOG.log(Level.SEVERE, "Error setting up mock video: " + e.getMessage(), e);
throw new RuntimeException("Failed to set up mock video", e);
}
}
@ -461,8 +466,9 @@ public class TestUtils {
}
/**
* Configures the test environment to ensure headless mode
* and prevent JavaFX initialization.
* Configure the test environment to ensure it's set up for headless operation.
* This sets various system properties to prevent JavaFX initialization
* and places the application in test mode.
*/
public static void configureTestEnvironment() {
// Set system properties to disable JavaFX
@ -481,12 +487,13 @@ public class TestUtils {
// Prevent JaceApplication from initializing JavaFX toolkit
JaceApplication.setupForTesting(true);
System.out.println("Test environment configured for headless mode");
LOG.fine("Test environment configured for headless mode");
}
/**
* Special test setup for CpuUnitTest to avoid JavaFX dependencies.
* This uses the headless mode and fake RAM to ensure reliable tests.
* Sets up the computer for CPU tests.
* This includes creating essential components, configuring the motherboard,
* setting up mock video, and clearing any cards/peripherals.
*/
public static void setupForCpuTest() {
// Configure test environment first
@ -498,7 +505,9 @@ public class TestUtils {
// Create bare minimum computer setup for CPU testing
Emulator.withComputer(c -> {
System.out.println("CPU Test Setup - Creating essential components");
if (VERBOSE_MODE) {
LOG.info("CPU Test Setup - Creating essential components");
}
// Replace any existing RAM with FakeRAM to avoid bank switching issues
FakeRAM ram = new FakeRAM();
@ -526,7 +535,9 @@ public class TestUtils {
throw new IllegalStateException("Video is null after setting it");
}
System.out.println("CPU Test Setup - Mock video initialized: " + c.getVideo().getClass().getSimpleName());
if (VERBOSE_MODE) {
LOG.info("CPU Test Setup - Mock video initialized: " + c.getVideo().getClass().getSimpleName());
}
// Disable all cards and peripherals
// In CPU tests we don't need any cards or peripherals
@ -542,10 +553,11 @@ public class TestUtils {
// Verify floating bus access works
try {
byte floatingBus = c.getVideo().getFloatingBus();
System.out.println("CPU Test Setup - Floating bus test successful (value: " + floatingBus + ")");
if (VERBOSE_MODE) {
LOG.info("CPU Test Setup - Floating bus test successful (value: " + floatingBus + ")");
}
} catch (Exception e) {
System.err.println("CPU Test Setup - Floating bus access failed: " + e.getMessage());
e.printStackTrace();
LOG.log(Level.WARNING, "CPU Test Setup - Floating bus access failed: " + e.getMessage(), e);
throw e;
}
} finally {
@ -564,12 +576,13 @@ public class TestUtils {
if (c.getVideo() == null) {
throw new IllegalStateException("Video is null after CPU test setup - this should never happen");
}
System.out.println("CPU Test Setup - Final verification successful, video = " + c.getVideo().getClass().getSimpleName());
if (VERBOSE_MODE) {
LOG.info("CPU Test Setup - Final verification successful, video = " + c.getVideo().getClass().getSimpleName());
}
});
} catch (Exception e) {
System.err.println("ERROR setting up CPU test environment: " + e.getMessage());
e.printStackTrace();
LOG.log(Level.SEVERE, "ERROR setting up CPU test environment: " + e.getMessage(), e);
throw new RuntimeException("Failed to set up CPU test environment", e);
}
}

View File

@ -14,6 +14,7 @@ import java.util.Set;
import java.util.TreeSet;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.logging.Logger;
import org.junit.Before;
import org.junit.Test;
@ -32,6 +33,8 @@ public class CpuUnitTest extends AbstractJaceTest {
// The goal is to produce an output report that shows the number of tests that passed and failed
// The output should be reported in a format compatible with junit but also capture multiple potential failures, not just the first faliure
private static final Logger LOG = Logger.getLogger(CpuUnitTest.class.getName());
TypeToken<Collection<TestRecord>> testCollectionType = new TypeToken<Collection<TestRecord>>(){};
record TestResult(String source, String testName, boolean passed, String message) {}
// Note cycles are a mix of int and string so the parser doesn't like to serialize that into well-formed objects
@ -123,11 +126,11 @@ public class CpuUnitTest extends AbstractJaceTest {
} else {
failedTests.add(result.testName());
if (failedTests.size() < 20) {
System.err.println(result.source() + ";" + result.testName() + " " + "FAILED" + ": " + result.message());
LOG.warning(result.source() + ";" + result.testName() + " " + "FAILED" + ": " + result.message());
}
}
}
System.err.println("Passed: " + passed + " Failed: " + failedTests.size());
LOG.info("Passed: " + passed + " Failed: " + failedTests.size());
if (failedTests.size() > 0) {
throw new RuntimeException("One or more tests failed, see log for details");
}

View File

@ -7,6 +7,7 @@ import org.junit.Test;
import jace.AbstractFXTest;
import jace.TestUtils;
import jace.core.VideoWriter;
import javafx.scene.image.WritableImage;
// This is mostly to provide execution coverage to catch null pointer or index out of range exceptions
@ -51,7 +52,9 @@ public class VideoDHGRTest extends AbstractFXTest {
@Test
public void testGetYOffset() {
// Run through all possible combinations of soft switches to ensure the correct Y offset is returned each time
// Make sure _80STORE is OFF so PAGE2 works correctly
SoftSwitches._80STORE.getSwitch().setState(false);
SoftSwitches[] switches = {SoftSwitches.HIRES, SoftSwitches.TEXT, SoftSwitches.PAGE2, SoftSwitches._80COL, SoftSwitches.DHIRES, SoftSwitches.MIXED};
for (int i=0; i < Math.pow(2.0, switches.length); i++) {
String state = "";
@ -61,8 +64,29 @@ public class VideoDHGRTest extends AbstractFXTest {
}
video.configureVideoMode();
int address = video.getCurrentWriter().getYOffset(0);
int expected = SoftSwitches.TEXT.isOn() || SoftSwitches.HIRES.isOff() ? (SoftSwitches.PAGE2.isOn() ? 0x0800 : 0x0400)
: (SoftSwitches.PAGE2.isOn() ? 0x04000 : 0x02000);
// Calculate expected address based on actual video mode logic
boolean page2 = SoftSwitches.PAGE2.isOn() && SoftSwitches._80STORE.isOff();
int expected;
if (SoftSwitches.TEXT.isOn()) {
// Text mode (including 80-column text)
expected = page2 ? 0x0800 : 0x0400;
} else if (SoftSwitches.HIRES.isOff()) {
// Lores mode (including double-lores when 80COL is ON)
expected = page2 ? 0x0800 : 0x0400;
} else {
// Hires mode (including double-hires when 80COL and DHIRES are ON)
expected = page2 ? 0x04000 : 0x02000;
}
// To help debug the specific failure cases
if (expected != address) {
System.out.println("Failed case: " + state);
System.out.println("Expected: " + expected + ", Actual: " + address);
System.out.println("Current Writer: " + video.getCurrentWriter().getClass().getName());
}
assertEquals("Address for mode not correct: " + state, expected, address);
}
}

View File

@ -38,6 +38,7 @@ import jace.apple2e.MOS65C02;
import jace.apple2e.RAM128k;
import jace.apple2e.SoftSwitches;
import jace.core.RAMEvent.TYPE;
import java.util.logging.Logger;
/**
* Test that memory listeners fire appropriately.
@ -49,6 +50,7 @@ public class MemoryTest {
static RAM128k ram;
static String MEMORY_TEST_COMMONS;
static String MACHINE_IDENTIFICATION;
static final Logger LOG = Logger.getLogger(MemoryTest.class.getName());
@BeforeClass
public static void setupClass() throws IOException, URISyntaxException {
@ -536,7 +538,7 @@ public class MemoryTest {
resetSoftSwitches();
for (int softswitch : testCase.softswitches) {
System.out.println("Setting softswitch " + Integer.toHexString(softswitch));
LOG.fine("Setting softswitch " + Integer.toHexString(softswitch));
ram.write(softswitch, (byte) 0, true, false);
}
for (int i=0; i < testLocations.length; i++) {

View File

@ -0,0 +1,268 @@
package jace.terminal;
import static org.junit.Assert.*;
import static org.mockito.Mockito.*;
import java.io.*;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.logging.ConsoleHandler;
import java.util.logging.Handler;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.junit.*;
import org.mockito.*;
import org.mockito.invocation.InvocationOnMock;
import org.mockito.stubbing.Answer;
import jace.Emulator;
import jace.apple2e.MOS65C02;
import jace.apple2e.RAM128k;
import jace.apple2e.SoftSwitches;
import jace.core.Computer;
import jace.core.RAM;
import jace.core.RAMEvent;
import jace.core.PagedMemory;
public class MainModeTest {
// Logger setup
private static final Logger LOG = Logger.getLogger(MainModeTest.class.getName());
// Control test output verbosity via system property:
// -Djace.test.debug=true to enable debug logs
private static final String DEBUG_PROPERTY = "jace.test.debug";
private static final boolean DEBUG_MODE = Boolean.getBoolean(DEBUG_PROPERTY);
private final ByteArrayOutputStream outContent = new ByteArrayOutputStream();
private final PrintStream originalOut = System.out;
private TestableMainMode mainMode;
private JaceTerminal mockTerminal;
private EmulatorInterface mockEmulator;
private Computer mockComputer;
private RAM128k mockRam;
private MOS65C02 mockCpu;
private PagedMemory mockPagedMemory;
// Track memory writes
private byte[] memoryValues = new byte[65536];
// Create a testable subclass that allows us to override the getCPU method
static class TestableMainMode extends MainMode {
private MOS65C02 testCpu;
public TestableMainMode(JaceTerminal terminal) {
super(terminal);
}
public void setTestCpu(MOS65C02 cpu) {
this.testCpu = cpu;
}
@Override
protected MOS65C02 getCPU() {
return testCpu != null ? testCpu : super.getCPU();
}
}
@BeforeClass
public static void setupLogging() {
// Configure logging based on system property
Level logLevel = DEBUG_MODE ? Level.FINE : Level.INFO;
LOG.setLevel(logLevel);
// Ensure handlers use our level
Logger rootLogger = Logger.getLogger("");
for (Handler handler : rootLogger.getHandlers()) {
if (handler instanceof ConsoleHandler) {
handler.setLevel(logLevel);
}
}
if (DEBUG_MODE) {
LOG.info("Debug mode enabled - verbose output will be displayed");
}
}
@Before
public void setUp() {
// Setup mocks
mockTerminal = mock(JaceTerminal.class);
mockEmulator = mock(EmulatorInterface.class);
mockComputer = mock(Computer.class);
mockRam = mock(RAM128k.class);
mockCpu = mock(MOS65C02.class);
mockPagedMemory = mock(PagedMemory.class);
// Wire mocks together
when(mockTerminal.getOutput()).thenReturn(new PrintStream(outContent));
when(mockTerminal.getEmulator()).thenReturn(mockEmulator);
// Set up the computer mock to return RAM and CPU
when(mockEmulator.withComputer(any(), any())).thenAnswer(invocation -> {
Function<Computer, Object> function = invocation.getArgument(0);
return function.apply(mockComputer);
});
when(mockComputer.getMemory()).thenReturn(mockRam);
when(mockComputer.getCpu()).thenReturn(mockCpu);
// Create MainMode instance with mocked terminal
mainMode = new TestableMainMode(mockTerminal);
LOG.fine("Test setup complete");
}
@After
public void tearDown() {
outContent.reset();
LOG.fine("Test cleaned up");
}
/**
* Helper method to log test output when in debug mode
*/
private void logOutput(String output) {
if (DEBUG_MODE) {
LOG.fine("Command output:\n" + output);
}
}
@Test
public void testMainModeName() {
// Test that MainMode returns the correct name
assertEquals("Main", mainMode.getName());
}
@Test
public void testMainModePrompt() {
// Test that MainMode returns the correct prompt
assertEquals("JACE> ", mainMode.getPrompt());
}
@Test
public void testHelpCommand() {
// Test the help output
mainMode.printHelp();
// Get the output
String output = outContent.toString();
logOutput(output);
// Verify the output contains expected help text
assertTrue("Help text should list available commands",
output.contains("Available commands:") &&
output.contains("monitor") &&
output.contains("assembler"));
}
@Test
public void testCommandHelp() {
// Test displaying help for a specific command
boolean result = mainMode.printCommandHelp("monitor");
// Get the output
String output = outContent.toString();
logOutput(output);
// Verify the output contains expected help for the monitor command
assertTrue("Command help should be displayed",
output.contains("monitor") &&
result == true);
}
@Test
public void testMonitorCommand() {
// Test the monitor command
boolean result = mainMode.processCommand("monitor");
// Verify setMode was called with "monitor"
verify(mockTerminal).setMode("monitor");
assertTrue("Command should be processed successfully", result);
}
@Test
public void testAssemblerCommand() {
// Test the assembler command
boolean result = mainMode.processCommand("assembler");
// Verify setMode was called with "assembler"
verify(mockTerminal).setMode("assembler");
assertTrue("Command should be processed successfully", result);
}
@Test
public void testDebuggerCommand() {
// Test the debugger command
boolean result = mainMode.processCommand("debugger");
// Verify setMode was called with "debugger"
verify(mockTerminal).setMode("debugger");
assertTrue("Command should be processed successfully", result);
}
@Test
public void testCommandAliases() {
// Test the monitor command alias
boolean result = mainMode.processCommand("m");
// Verify setMode was called with "monitor"
verify(mockTerminal).setMode("monitor");
assertTrue("Command should be processed successfully", result);
}
@Test
public void testRegistersCommand() {
LOG.fine("Starting testRegistersCommand");
// Set up CPU with register values
mockCpu.A = 0xAA;
mockCpu.X = 0xBB;
mockCpu.Y = 0xCC;
mockCpu.STACK = 0xDD;
when(mockCpu.getProgramCounter()).thenReturn(0xEEFF);
// Set up CPU flags
mockCpu.Z = true;
mockCpu.C = 1;
mockCpu.I = true;
mockCpu.D = true;
// Use our testable subclass to directly set the CPU
mainMode.setTestCpu(mockCpu);
// Test the registers command
boolean result = mainMode.processCommand("registers");
// Verify the result
assertTrue("Command should be processed successfully", result);
// Get the output
String output = outContent.toString();
logOutput(output);
// Verify the output contains register values with the correct format
assertTrue("Output should include register values",
output.contains("CPU Registers:") &&
output.contains("A: $") &&
output.contains("X: $") &&
output.contains("Y: $") &&
output.contains("PC: $") &&
output.contains("S: $") &&
output.contains("Flags:"));
}
@Test
public void testUnknownCommand() {
// Test an unknown command
boolean result = mainMode.processCommand("unknowncommand");
// Verify the result is false (command not recognized)
assertFalse("Unknown command should return false", result);
// Verify error message is displayed
String output = outContent.toString();
logOutput(output);
assertTrue("Output should include error message", output.contains("Unknown command"));
}
}