Skip to content

Core Test Suite

opencode-agent[bot] edited this page Oct 4, 2026 · 1 revision

Core Test Suite

CoreTestSuite is the single @RunWith(Suite.class) entry point that core/build-tests.xml runs; it went from 8 classes / 203 tests to 14 classes / 272 runnable tests in one week by converting stranded main() and JUnit 3 classes into host-runnable JUnit 4.

Overview

core/src/test/org/jnode/test/CoreTestSuite.java is the only class core/build-tests.xml's all-junit target names in its <batchtest> fileset, so a test class that is not listed in @SuiteClasses does not run at all — it compiles into core/build/testclasses and silently disappears. This is the same failure mode that hit the shell subproject in Testing.

@RunWith(Suite.class)
@SuiteClasses({
    NumberUtilsTest.class,  ResourceTest.class,   SignatureTest.class,
    VersionTest.class,      TryFinallyTest.class,  VarArgsTest.class,
    ForEachTest.class,      ViewMethodTest.class, DoubleTest.class,
    WaitTest.class,         JavaCommandParserTest.class,
    ThreadingTestJUnit4.class, X86RegisterPoolTest.class,
    X86StreamTest.class,
})
public class CoreTestSuite { }
Run Classes @Test methods
Before 2026-09-27 8 203
After (2026-10-03) 14 273 annotations / 272 runnable (X86StreamTest.testDumpStreamToFile is @Ignored)

VersionTest alone contributes 95 of those.

What "Host Runnable" Means Here

These tests run in the build host's OpenJDK against the JNode core jar, so they can only assert behaviour that is true on any conforming JVM. Three consequences shape the whole suite:

  • Anything needing a live JNode VM (VmType.fromClass, VmMethod.getNativeCodeOptLevel(), VmSystem, InitialNaming.lookup) must either be dropped or guarded by Assume.
  • Any production class reached through BootLogInstance.get() NPEs off-VM because NAME_SPACE is null — that, not test quality, is why ResourceTest was excluded from the suite for its whole JUnit 3 life.
  • The pure-Java parts of the VM are fair game: X86BinaryAssembler needs no VM, which is what made 21 assembler tests possible.

Key Components

Class @Test Role
org.jnode.test.util.VersionTest 95 Version parsing/ordering/hashCode; the reference example for a banner-grouped expansion
org.jnode.test.util.NumberUtilsTest 33 org.jnode.util.NumberUtils hex/size formatting
org.jnode.test.ResourceTest 25 ClassLoader/Class/ResourceBundle resource lookup and delegation
org.jnode.test.X86StreamTest 22 (21 run) org.jnode.assembler.x86.X86BinaryAssembler encoding
org.jnode.test.SignatureTest 17 VmType-independent signature parsing
org.jnode.test.VarArgsTest 15 varargs bytecode generation
org.jnode.test.JavaCommandParserTest 11 command-line parsing helpers
org.jnode.test.TryFinallyTest 9 try/finally and exception-table codegen
org.jnode.test.DoubleTest 9 JLS §5.1.3 double → int narrowing rules
org.jnode.test.threads.ThreadingTestJUnit4 10 thread start/join against the host JVM
org.jnode.vm.x86.compiler.l1a.X86RegisterPoolTest 8 L1A register allocation spill bookkeeping
org.jnode.test.ForEachTest 7 for-each over arrays and collections
org.jnode.test.WaitTest 6 Object.wait/notify/notifyAll semantics
org.jnode.test.ViewMethodTest 6 method metadata; the one class with a guest-only test

The 2026-10-03 Wiring Campaign

Five issues (#489, #695, #696, #697 and the suite-wiring follow-ups) converted every remaining stranded class. The recurring blockers were always the same three JUnit facts.

1. JUnit 4 rejects JUnit 3 and main() classes

A junit.framework.TestCase subclass or a plain class with only main() in a @SuiteClasses list fails with initializationError: No runnable methods. Conversion means: drop extends TestCase, drop suite(), replace failUnless/assertTrue with statically imported org.junit.Assert.*, and rename testXxx() → @Test public void x() or keep the name and annotate it.

2. Assume splits host from guest

ViewMethodTest is the template. The original dumped compiled code through VmType, which only exists inside JNode. The rewrite checks metadata by reflection (host-runnable) and keeps one cross-check behind a VM probe:

@Test
public void testVmTypeReportsTheSameMethods() throws Exception {
    Assume.assumeTrue(isInsideJNodeVm());          // ViewMethodTest.java:117
    ...
}

private static boolean isInsideJNodeVm() {          // :137-139
    return "JNode".equals(System.getProperty("java.vm.name"));
}

java.vm.name is set by JNode in core/src/core/org/jnode/vm/VmSystem.java:315, so the probe is a property read, not a boot-logic call. Five of the six tests therefore still run in CI; the sixth is skipped, not failed.

getDeclaredMethods() order is JVM-unspecified, so testDeclaredMethodNames collects into a HashSet and compares sets rather than arrays.

3. Sleeping is not synchronising

WaitTest was a main() stress loop that sequenced 10 threads with a fixed Thread.sleep(2000). The rewrite is a counter plus a deadline:

private static final long JOIN_TIMEOUT = 20000;     // WaitTest.java:43
private static final long WAIT_TIMEOUT  = 300;      // :44

private void waitUntilEntered(WaitTest wt, int n) throws InterruptedException {  // :185-192
    long deadline = System.currentTimeMillis() + JOIN_TIMEOUT;
    while (wt.getEnteredCount() < n) {
        assertTrue("waiter never entered wait()", System.currentTimeMillis() < deadline);
        Thread.sleep(1);
    }
}

Every join is join(JOIN_TIMEOUT) followed by assertFalse(t.isAlive()), so a lost wakeup is a failure, not a hang. Five orthogonal counters (entered, finished, woken, timedOut, interrupted, :46-51) let a test assert how a thread left the wait; all accesses are inside synchronized methods, which is why the fields do not need volatile.

Coverage added: notifyAll releases all 10 waiters, notify wakes a waiter (deliberately started with exactly one waiter so the assertion does not depend on JVM waiter-selection policy), timed wait expiry, timed wait woken by notifyAll, interruption (InterruptedException → interrupted == 1, woken == 0), and the already-signalled fast path returning without incrementing entered.

4. Locale must be restored, not just set

ResourceTest's JUnit 3 version left Locale.getDefault() mutated at Locale.FRENCH, leaking into whichever test ran next in the suite. The JUnit 4 version brackets every locale change:

@Before public void saveLocale()   { saved = Locale.getDefault(); }
@After  public void restoreLocale() { Locale.setDefault(saved); }

Locale.setDefault still goes through AccessController.doPrivileged (ResourceTest.java:332-339) so it works under a security manager.

ResourceTest — the ClassLoader vs Class Slash Rule

25 tests in five banner groups (:78, :116, :150, :182, :236): ClassLoader lookup, system-classloader lookup, parent delegation, Class.getResource, and ResourceBundle.

The single most important encoded fact:

ClassLoader.getResource does NOT strip a leading /. Class.getResource does.

The JUnit 3 version carried two "absolute path" ClassLoader cases that could never pass; they were deleted rather than fixed. Every ClassLoader test therefore passes addRoot = false to the relativeToAbsolutePath helper (:301-305), while only the Class.getResource tests pass addRoot = true.

Other pinned behaviour:

  • ClassLoader.getSystemResource(name) returns the same URL.toExternalForm() as Class.getClassLoader().getResource(name) (:126-133).
  • Delegation: an anonymous new ClassLoader(parent) {} must resolve the parent's URL and must return null for an unknown resource — a child must not fabricate.
  • ResourceBundle.getBundle("messages") with a relative base name throws MissingResourceException; base names must be fully qualified.
  • The loaded bundle must be exactly PropertyResourceBundle.class (:327). Fixtures are messages.properties, messages_en_US.properties, messages_fr.properties in org/jnode/test/; because messages_en_US carries the same testok value, the Locale.US case cannot distinguish base from _en_US load.
  • ResourceBundle caches per (baseName, locale, loader), so the US-then-FRENCH sequence order matters.

Dropped coverage: the JUnit 3 doGetLocalizedMessage test for PluginUtils.getLocalizedMessage is gone. It calls BootLogInstance.get() → InitialNaming.lookup(BootLog.class), and NAME_SPACE is null on a host JVM, so it NPEs. org.jnode.util.PluginUtils is no longer imported by the file. Restoring it needs a BootLog binding.

X86StreamTest — Assembler Regression Coverage

The assembler is pure Java, so newAssembler always builds against a synthetic CPU, never the live one:

private static X86BinaryAssembler newAssembler(Mode mode) {   // X86StreamTest.java:60-62
    return new X86BinaryAssembler(X86CpuID.createID("pentium4"), mode);
}

Both Mode.CODE32 and Mode.CODE64 are exercised. The suite went from 0 methods / 0 assertions to 22/40, and the dead code it removed was hiding a real bug:

  • The old testCode32 had if (true) { return; } right after the IDIV loop, disabling roughly 100 instructions. Deleting the short-circuit exposed writeMOV(BITS8, ECX, EBX, 1, 4, ESI) — ESI has no 8-bit sub-register, so X86BinaryAssembler.testSuitableForBits8 (X86BinaryAssembler.java:773, called from :3063) rejects it. Fixed to EDX at X86StreamTest.java:455.
  • The old main() dumped a binary file into the working directory. That survives as testDumpStreamToFile with @Ignore("manual debugging aid: dumps the 32 bit stream to test.bin").

Encoding facts pinned by golden-byte assertions:

Fact Assertion
Relative displacement is measured from the end of the jump instruction rel32 back-patch get32(1) == 3; rel8 get8(2) == 1; short forward jump (byte) -2; distant jump get32(201) == -205
A zero-distance rel jump shrinks to NOPs 0xE9 rel32 becomes 0x90 0x90 0x90 0x90 0x90, total length 5
Indirect jump table entries are biased by the opcode size writeJMP(label, 2, false) → 0xFF 0x25 + labelOffset + 2, 6 bytes total
writeJECXZ0 emits an address-size prefix 0x67 before 0xE3
A label stays unresolved until a later setObjectRef back-patches it the jump is emitted first, then addUnresolvedLink → resolve8/resolve32 (X86BinaryAssembler.java:180, :286, :318). Pre-resolving the label before writeJMP was rejected in review because it never reaches the resolver
IDIV is exactly 2 bytes 5 registers → getLength() == 10, first bytes 0xF7 0xF8
64-bit forms writeCDQE → 48 98; mov rax, imm64 → 48 B8 + 8 little-endian bytes, total 12; get64(4) == 0x1234L
clear() resets the cursor and the object-ref table allocate(4) then allocate(8) returns 0 then 4, total 12
Duplicate labels are rejected @Test(expected = RuntimeException.class); the message text is not asserted
An unresolved ObjectRef throws on getOffset() only the non-empty message is asserted, not the wording

writeTo(OutputStream) is compared against getBytes() truncated to getLength(), which proves the stream write is repeatable and length-consistent.

DoubleTest — Narrowing as Inequalities

9 methods, but loop-driven, so ~1,000 dynamic evaluations. All use one helper so the narrowing happens in exactly one place:

private static int d2i(double d) { return (int) d; }   // DoubleTest.java:40-42

Pinned JLS §5.1.3 rules:

  • Truncation is toward zero, not floor: -0.5 → 0, -1.9 → -1, -2.5 → -2 (encoded as expected = (i < 0) ? i + 1 : i, :74).
  • NaN → 0.
  • Out-of-range saturates rather than wraps: +Inf → Integer.MAX_VALUE, -Inf → Integer.MIN_VALUE; ±1e18 the same.
  • The sweep d += 0.125 accumulates float error, so testFractionalPartIsAlwaysBelowOne asserts inequalities — truncated <= d for d >= 0, truncated >= d for d < 0, |d - truncated| < 1.0 — not exact values.
  • i + 0.5 is exact in binary64 for integral i, which is what makes the expected values in the truncation sweep reliable.

testNarrowingRoundTrip is loosely named: d - 0.75 == i exactly (both operands representable, |i| ≤ 50), so it degenerates into an int→double→int check.

ForEachTest

7 tests, host-pure java.lang bytecode-generation checks. Determinism comes from comparing against list.get(i) or a monotonic counter inside the loop rather than a hardcoded expectation list, so the tests survive whichever for-each desugaring the compiler picks.

  • testEmptyArray — the body must never run (counter stays 0); zero length must not throw.
  • testNullElementsArePassedThrough — a null element is delivered as null, not skipped and not NPE'd.
  • testArrayIsCopiedBeforeIteration — labelled as the JLS 5.1.3 copy-at-loop-entry case, but vacuous with respect to copy semantics: it mutates a[0] after index 0 has been read and never touches a[1]/a[2], so the expected 1,2,3 holds under both a snapshot copy and index-by-index reading. Mutating a[1] would be the discriminating mutation. (The governing spec section for for-each is 14.4.2/15.11; §5.1.3 is Binary Numeric Promotion.)

NumberUtilsTest and the Two Latent Production Bugs

33 methods / ~155 assertions, up from 13 methods with one assertion each. Shared fixtures (:33-37): BYTES = {0x00,0x0F,0xA5,0xFF}, INTS = {1,255,65535,-1,0x12345678}, CHARS = {'A',0x01,0xFFFE,'z'}.

Now covered: both toUnsigned primitive overloads, hex(int,int)/hex(long,int) plus the single-argument forms, hex(byte[]), hex(byte[],int,int) with line wrapping, hexCompact(byte[],int,int), hex(int[],int,int), hex(char[],int,int), the deprecated size(long), both toDecimalByte and both toBinaryByte nbDecimals overloads, and the null/blank paths of getSize/getSizeUnit.

Non-obvious pinned behaviour:

  • toString truncates, never rounds — toString(15.5f, 2) == "15.5", and maxFractionLength == 0 yields the degenerate "15." with the trailing dot kept.
  • prefixZero truncates from the left: hex(255, 0) == "" and hex(0xFFFFFFFF, 4) == "ffff" keeps the LOW 4 digits.
  • The % 16 == 0 line wrap: 20 bytes → 16 on the first line, 11 12 13 14 on the second. The compact form wraps too but emits no separator space.
  • Negative bytes wrap via & 0xFF; hex(int[]) renders -1 as ffffffff; hex(char[]) passes the char straight to hex(int,int), which is why 0xFFFE prints as fe and not fffe.
  • getSizeUnit matches suffixes case-sensitively: "1k" → null, while "100"/"0" return B. getSize trims (" 1K " → 1024) and maps null/blank to 0.

Two real production bugs were found while deriving the expected values and are deliberately not asserted, because the fixes belong in core/src/core/org/jnode/util/NumberUtils.java:

Bug A — getSizeUnit("1KB") returns SizeUnit.B, so getSize("1KB") throws

getSizeUnit (NumberUtils.java:327-332) iterates SizeUnit.values() in declaration order and returns the first suffix match. B is declared first (core/src/core/org/jnode/util/SizeUnit.java:30), and "1KB".endsWith("B") is true, so K at SizeUnit.java:31 is never reached. getSize then takes the sizeUnit != null branch, sets multiplier = 1, strips only one character (NumberUtils.java:316), and calls Long.parseLong("1K") at :319:

getSizeUnit("1KB") = B
getSize("1KB") -> java.lang.NumberFormatException: For input string: "1K"

A fix is longest-suffix-wins in getSizeUnit, or stripping the full unit in getSize.

Bug B — toString(f, Integer.MAX_VALUE) overflows an int

NumberUtils.java:38 computes:

final int len = Math.min(s.length(), idx + maxFractionLength + 1);

With maxFractionLength == Integer.MAX_VALUE and idx == 2 (for "15.2365"), the sum overflows to -2147483646, so s.substring(0, -2147483646) at :39 throws:

toString(15.2365f, Integer.MAX_VALUE) -> StringIndexOutOfBoundsException: String index out of range: -2147483646
toDecimalByte(1234567, Integer.MAX_VALUE) -> StringIndexOutOfBoundsException: ... -2147483647

This contradicts the documented contract: NumberUtils.java:274 and :285 both say "use Integer.MAX_VALUE for all" for nbDecimals, and DecimalScaleFactor.apply / BinaryScaleFactor.apply pass nbDecimals straight through, so those two are broken too. The fix belongs in toString (clamp to s.length(), or use long arithmetic).

Consequence for the suite: every nbDecimals test uses a small value (0-4), so the Integer.MAX_VALUE sentinel path is left uncovered rather than pinned.

Gotchas

  • build-tests.xml names exactly one class. core/build-tests.xml's <batchtest> fileset is org/jnode/test/CoreTestSuite.java. Being a @Test class in the right directory is not enough.
  • No runnable methods is the signature of a JUnit 3 TestCase or a main()-only class left in a JUnit 4 suite.
  • Off-VM BootLogInstance.get() NPEs. Any production path that starts at a named-object lookup cannot be unit tested off-VM.
  • java.vm.name == "JNode" is the cheapest available "am I in the guest" probe (VmSystem.java:315).
  • getDeclaredMethods() order is unspecified — compare HashSets.
  • A sleeping test is not a synchronised test. Use an entry counter plus a deadline so a lost wakeup fails instead of hanging.
  • Locale.getDefault() is global mutable state. Bracket it with @Before/@After or it leaks into the next suite member.
  • ClassLoader.getResource keeps a leading /; Class.getResource strips it. A single path helper with an addRoot flag is what keeps the two halves honest.
  • Relative ResourceBundle base names always throw.
  • Relative branch displacements are measured from the end of the instruction, which is why a golden-byte test is worth more than a "does it assemble" test.
  • An assembler label is only resolved by a later setObjectRef. Resolving it before the jump skips the whole back-patch path.
  • Sizing the test-only merge allowance is a live trade-off — see Agent-Issue-Pipeline; isDiffSafe currently allows 600 added lines when every changed path matches /(^|\/)(src\/test\/|tests\/)/, still capped at 5 files.

Related Pages

  • Testing — Hub: subproject build-tests.xml convention, boot testing, JDWP suite.
  • JNAsm-Instruction-Encoding — the textual assembler's encoding tables, as opposed to X86BinaryAssembler's runtime emit path that X86StreamTest covers.
  • X87-FPU-Support — the guest-side main() test programs (ConversionTest, RintTest, StrictMathTest) that cover what host JUnit cannot.
  • Agent-Issue-Pipeline — the automation that produced these conversions and the merge gate that admits them.
  • Code-Conventions — Java 1.6 target that these tests are written against.

Clone this wiki locally