Skip to content

Commit 0adcc78

Browse files
dmealingclaude
andcommitted
test(jvm-codegen): cover Kotlin long/double/bool scalar arrays + Java extract throw-on-lost-required + Kotlin optional-absent
Gap A: Kotlin compile-run extractor fixture extended with required List<Long>/List<Double>/List<Boolean> scalar arrays (counts/weights/active), asserting typed values + boxed element types — exercising the it.toLong()/ it.toDouble()/it.toBoolean() element parses the int/float fixture left untested. Gap B: Java GeneratedExtractorCompileRunTest now asserts the generated <Name>Extractor.extract(loader, lostRequiredText) throws — reflectively unwraps InvocationTargetException and asserts the cause is com.metaobjects.render.extract.ExtractException (a RuntimeException) naming the lost required path, for both pojoAware + valueObject flavors. Gap C (Kotlin optional-absent -> null): N/A. KotlinPayloadGenerator types every payload field non-null (via KotlinTypeMapper.kotlinTypeName, no @required-based nullability) — same all-required-payload shape as the C# port — so an absent-optional cannot be represented as null; documented as the no-skew invariant in KotlinExtractorGenerator. Test-only. codegen-kotlin + codegen-spring: 81 tests green, no regression. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 47b4cf5 commit 0adcc78

2 files changed

Lines changed: 56 additions & 1 deletion

File tree

server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinExtractorCompilesTest.kt

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ import kotlin.test.fail
2626
* - REQUIRED string scalar-array (tags: List<String>)
2727
* - REQUIRED int scalar-array (scores: List<Int>) — proves non-string scalar arrays (the C# bug)
2828
* - REQUIRED float scalar-array (ratings: List<Float>) — proves a non-Int numeric element parse
29+
* - REQUIRED long scalar-array (counts: List<Long>) — proves the Long element parse (it.toLong())
30+
* - REQUIRED double scalar-array (weights: List<Double>) — proves the Double element parse
31+
* - REQUIRED bool scalar-array (active: List<Boolean>) — proves the Boolean element parse
2932
* - REQUIRED enum scalar-array (flags: List<String>) — proves the enum element type passthrough
3033
* - OPTIONAL scalar (note: String)
3134
*
@@ -51,6 +54,9 @@ class KotlinExtractorCompilesTest {
5154
{ "field.string": { "name": "tags", "isArray": true, "@required": true } },
5255
{ "field.int": { "name": "scores", "isArray": true, "@required": true } },
5356
{ "field.float": { "name": "ratings", "isArray": true, "@required": true } },
57+
{ "field.long": { "name": "counts", "isArray": true, "@required": true } },
58+
{ "field.double": { "name": "weights", "isArray": true, "@required": true } },
59+
{ "field.boolean": { "name": "active", "isArray": true, "@required": true } },
5460
{ "field.enum": { "name": "flags", "isArray": true, "@required": true, "@values": ["A", "B"] } },
5561
{ "field.string": { "name": "note" } }
5662
] } },
@@ -102,6 +108,9 @@ class KotlinExtractorCompilesTest {
102108
"\"tags\":[\"x\",\"y\"]," +
103109
"\"scores\":[3,7]," +
104110
"\"ratings\":[1.5,2.5]," +
111+
"\"counts\":[10,20]," +
112+
"\"weights\":[1.25,2.75]," +
113+
"\"active\":[true,false]," +
105114
"\"flags\":[\"A\",\"B\"]," +
106115
"\"note\":\"hi\",}\n```"
107116

@@ -135,6 +144,24 @@ class KotlinExtractorCompilesTest {
135144
assertEquals(listOf(1.5f, 2.5f), ratings,
136145
"float scalar-array must populate as typed List<Float> (non-Int numeric element parse)")
137146

147+
@Suppress("UNCHECKED_CAST")
148+
val counts = orderClass.getDeclaredMethod("getCounts").invoke(order) as List<Long>
149+
assertEquals(listOf(10L, 20L), counts,
150+
"long scalar-array must populate as typed List<Long> (it.toLong() element parse)")
151+
counts.forEach { assertTrue(it is Long, "counts element must be a boxed Long, got ${it!!::class}") }
152+
153+
@Suppress("UNCHECKED_CAST")
154+
val weights = orderClass.getDeclaredMethod("getWeights").invoke(order) as List<Double>
155+
assertEquals(listOf(1.25, 2.75), weights,
156+
"double scalar-array must populate as typed List<Double> (it.toDouble() element parse)")
157+
weights.forEach { assertTrue(it is Double, "weights element must be a boxed Double, got ${it!!::class}") }
158+
159+
@Suppress("UNCHECKED_CAST")
160+
val active = orderClass.getDeclaredMethod("getActive").invoke(order) as List<Boolean>
161+
assertEquals(listOf(true, false), active,
162+
"boolean scalar-array must populate as typed List<Boolean> (it.toBoolean() element parse)")
163+
active.forEach { assertTrue(it is Boolean, "active element must be a boxed Boolean, got ${it!!::class}") }
164+
138165
@Suppress("UNCHECKED_CAST")
139166
val flags = orderClass.getDeclaredMethod("getFlags").invoke(order) as List<String>
140167
assertEquals(listOf("A", "B"), flags,
@@ -156,7 +183,8 @@ class KotlinExtractorCompilesTest {
156183
val extractLenientMethod = extractorClass.getDeclaredMethod("extractLenient", loaderClass, String::class.java)
157184
val clean = "{\"customer\":{\"name\":\"Ada\"}," +
158185
"\"lines\":[{\"sku\":\"A\",\"qty\":1}]," +
159-
"\"tags\":[\"x\"],\"scores\":[3],\"ratings\":[1.5],\"flags\":[\"A\"],\"note\":\"hi\"}"
186+
"\"tags\":[\"x\"],\"scores\":[3],\"ratings\":[1.5]," +
187+
"\"counts\":[10],\"weights\":[1.25],\"active\":[true],\"flags\":[\"A\"],\"note\":\"hi\"}"
160188
val rr = extractLenientMethod.invoke(extractorInstance, loader, clean)
161189
val reportClass = cl.loadClass("com.metaobjects.render.extract.ExtractionReport")
162190
val report = rr.javaClass.getMethod("report").invoke(rr)

server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/GeneratedExtractorCompileRunTest.java

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,33 @@ private void runFlavor(String flavor, String loaderName) throws Exception {
262262
Object extracted = result.getClass().getMethod("data").invoke(result);
263263
assertSame("extractLenient().data() must be the generated flavored type",
264264
answerCls, extracted.getClass());
265+
266+
// --- extract(loader, lostRequired) — the strict orThrow() gate fires ---
267+
// The payload marks `title` @required. Feed input that loses it (well-formed JSON with
268+
// NO title); the generated extract(...) must throw the strict lost-required exception
269+
// (com.metaobjects.render.extract.ExtractException, a RuntimeException), surfaced via
270+
// reflection as an InvocationTargetException whose cause is that type.
271+
String lostRequired = "{\"count\":3,"
272+
+ "\"address\":{\"city\":\"Austin\",\"zip\":\"78701\"},"
273+
+ "\"items\":[{\"sku\":\"A1\",\"qty\":2}]}";
274+
Class<?> extractExceptionCls = cl.loadClass("com.metaobjects.render.extract.ExtractException");
275+
try {
276+
extract.invoke(null, loader, lostRequired);
277+
fail("extract(...) must throw when a required field is lost (flavor=" + flavor + ")");
278+
} catch (java.lang.reflect.InvocationTargetException ite) {
279+
Throwable cause = ite.getCause();
280+
assertNotNull("InvocationTargetException must carry a cause (flavor=" + flavor + ")", cause);
281+
assertSame("extract(...) lost-required cause must be ExtractException (flavor=" + flavor
282+
+ "); got " + cause.getClass().getName(),
283+
extractExceptionCls, cause.getClass());
284+
assertTrue("ExtractException must be a RuntimeException",
285+
cause instanceof RuntimeException);
286+
// and it must name the lost required path
287+
@SuppressWarnings("unchecked")
288+
List<String> lost = (List<String>) cause.getClass().getMethod("lostRequired").invoke(cause);
289+
assertTrue("lostRequired() must name `title` (flavor=" + flavor + "); got " + lost,
290+
lost.contains("title"));
291+
}
265292
}
266293
}
267294
}

0 commit comments

Comments
 (0)