Skip to content

Commit 55e3cb1

Browse files
dmealingclaude
andcommitted
test(output-prompt-conformance): Kotlin runner over shared JVM engine (10/10)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d8ac40b commit 55e3cb1

2 files changed

Lines changed: 185 additions & 0 deletions

File tree

server/java/codegen-kotlin/pom.xml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,22 @@
4343
</dependency>
4444

4545
<!-- test -->
46+
<!-- Shared JVM render engine (OutputFormatRenderer + Recover): the FR-010 output-prompt
47+
conformance runner drives these Java classes directly, proving the corpus loads/runs
48+
in the Kotlin module. Brings jackson-databind transitively for spec.json parsing. -->
49+
<dependency>
50+
<groupId>com.metaobjects</groupId>
51+
<artifactId>metaobjects-render</artifactId>
52+
<version>${project.version}</version>
53+
<scope>test</scope>
54+
</dependency>
55+
<!-- spec.json parsing for the output-prompt-conformance runner (jackson is test-scoped in
56+
render, so it is not transitive — declare it directly). -->
57+
<dependency>
58+
<groupId>com.fasterxml.jackson.core</groupId>
59+
<artifactId>jackson-databind</artifactId>
60+
<scope>test</scope>
61+
</dependency>
4662
<dependency>
4763
<groupId>org.jetbrains.kotlin</groupId>
4864
<artifactId>kotlin-test-junit5</artifactId>
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
package com.metaobjects.generator.kotlin
2+
3+
import com.fasterxml.jackson.databind.JsonNode
4+
import com.fasterxml.jackson.databind.ObjectMapper
5+
import com.metaobjects.render.prompt.OutputFormatRenderer
6+
import com.metaobjects.render.prompt.OutputFormatSpec
7+
import com.metaobjects.render.prompt.PromptField
8+
import com.metaobjects.render.prompt.PromptOverrides
9+
import com.metaobjects.render.prompt.PromptStyle
10+
import com.metaobjects.render.recover.FieldKind
11+
import com.metaobjects.render.recover.FieldRecovery
12+
import com.metaobjects.render.recover.FieldSpec
13+
import com.metaobjects.render.recover.Format
14+
import com.metaobjects.render.recover.Recover
15+
import com.metaobjects.render.recover.RecoverOptions
16+
import com.metaobjects.render.recover.RecoverSchema
17+
import java.nio.charset.StandardCharsets
18+
import java.nio.file.Files
19+
import java.nio.file.Path
20+
import kotlin.test.Test
21+
import kotlin.test.assertEquals
22+
import kotlin.test.assertFalse
23+
import kotlin.test.assertTrue
24+
25+
/**
26+
* Cross-port byte-identity gate for the FR-010 output-format prompt fragment — Kotlin runner.
27+
*
28+
* Kotlin REUSES the shared JVM `render` engine (the same Java [OutputFormatRenderer] and
29+
* [Recover] classes that the Java runner validates). This runner drives those Java classes
30+
* directly against the shared corpus at `fixtures/output-prompt-conformance/`, proving the
31+
* corpus loads + runs in the Kotlin module. Byte-identity to Java/TS/C#/Python holds by
32+
* construction (same engine); the value here is the loads-in-Kotlin guarantee.
33+
*
34+
* Transliterated from `render`'s OutputPromptConformanceTest.java. Assertions are ordinal
35+
* String equality on raw UTF-8 bytes with no newline translation — the corpus is the oracle.
36+
*/
37+
class OutputPromptConformanceTest {
38+
39+
private val json = ObjectMapper()
40+
41+
/** Count guard: a port silently skipping cases must fail. Bump when adding cases. */
42+
private val expectedCaseCount = 10
43+
44+
private data class StyleSpec(val key: String, val style: PromptStyle)
45+
46+
private val styles = listOf(
47+
StyleSpec("guide", PromptStyle.GUIDE),
48+
StyleSpec("inline", PromptStyle.INLINE),
49+
StyleSpec("exampleOnly", PromptStyle.EXAMPLE_ONLY),
50+
)
51+
52+
private fun corpus(): Path {
53+
var p: Path? = Path.of(System.getProperty("user.dir")).toAbsolutePath()
54+
while (p != null && !Files.exists(p.resolve("fixtures/output-prompt-conformance"))) {
55+
p = p.parent
56+
}
57+
assertTrue(p != null, "could not locate fixtures/output-prompt-conformance from user.dir")
58+
return p!!.resolve("fixtures/output-prompt-conformance")
59+
}
60+
61+
private fun cases(): List<Path> {
62+
val root = corpus()
63+
assertTrue(Files.isDirectory(root), "corpus missing at $root")
64+
return Files.list(root).use { stream ->
65+
stream.filter { Files.isDirectory(it) }
66+
.filter { Files.exists(it.resolve("spec.json")) }
67+
.sorted()
68+
.toList()
69+
}
70+
}
71+
72+
@Test
73+
fun `reproduces corpus bytes for every case`() {
74+
val dirs = cases()
75+
assertEquals(expectedCaseCount, dirs.size, "output-prompt-conformance case count")
76+
77+
for (dir in dirs) {
78+
val spec = json.readTree(dir.resolve("spec.json").toFile())
79+
val ofs = buildOutputSpec(spec)
80+
81+
for (s in styles) {
82+
val overrides = PromptOverrides(s.style, emptyMap<String, String>(), emptyMap<String, String>())
83+
val actual = OutputFormatRenderer.render(ofs, overrides)
84+
val expected = readUtf8(dir.resolve("expected.${s.key}.txt"))
85+
assertEquals(expected, actual, "$dir style[${s.key}]")
86+
// determinism: identical across runs
87+
assertEquals(
88+
actual,
89+
OutputFormatRenderer.render(ofs, overrides),
90+
"$dir style[${s.key}] (determinism)",
91+
)
92+
}
93+
94+
if (spec.has("roundTrip") && spec.get("roundTrip").asBoolean()) {
95+
val exampleFragment = readUtf8(dir.resolve("expected.exampleOnly.txt"))
96+
val outcome = Recover.recover(exampleFragment, buildRecoverSchema(spec), RecoverOptions.defaults())
97+
// Skew guard: a field the renderer emitted must read back cleanly. Assert NO state
98+
// is MALFORMED or LOST_* (robust to DEFAULTED and to how a nested OBJECT-container
99+
// path classifies); any such state means the renderer emitted an example the
100+
// recover parser cannot read.
101+
for ((field, state) in outcome.report().states()) {
102+
val bad = state == FieldRecovery.MALFORMED ||
103+
state == FieldRecovery.LOST_REQUIRED ||
104+
state == FieldRecovery.LOST_OPTIONAL
105+
assertFalse(bad, "$dir roundTrip state[$field]=$state")
106+
}
107+
}
108+
}
109+
}
110+
111+
private fun readUtf8(p: Path): String = String(Files.readAllBytes(p), StandardCharsets.UTF_8)
112+
113+
// ----- descriptor (spec.json) -> renderer/recover model -----
114+
115+
private fun buildOutputSpec(c: JsonNode): OutputFormatSpec {
116+
val fmt = toFormat(c.get("format").asText())
117+
val root = c.get("rootName").asText()
118+
val fields = c.get("fields").map { buildPromptField(it) }
119+
// style is a placeholder; each render overrides it per style.
120+
return OutputFormatSpec(fmt, root, PromptStyle.GUIDE, fields)
121+
}
122+
123+
private fun buildPromptField(f: JsonNode): PromptField {
124+
val name = f.get("name").asText()
125+
val kind = FieldKind.valueOf(f.get("kind").asText())
126+
val required = f.has("required") && f.get("required").asBoolean()
127+
val array = f.has("array") && f.get("array").asBoolean()
128+
val enumValues: List<String>? =
129+
if (f.has("enumValues") && !f.get("enumValues").isNull) f.get("enumValues").map { it.asText() } else null
130+
val enumDoc: Map<String, String>? =
131+
if (f.has("enumDoc") && !f.get("enumDoc").isNull) {
132+
val m = LinkedHashMap<String, String>()
133+
f.get("enumDoc").fields().forEachRemaining { m[it.key] = it.value.asText() }
134+
m
135+
} else {
136+
null
137+
}
138+
val example = if (f.has("example") && !f.get("example").isNull) f.get("example").asText() else null
139+
val instruction = if (f.has("instruction") && !f.get("instruction").isNull) f.get("instruction").asText() else null
140+
val nested = if (f.has("nested") && !f.get("nested").isNull) buildOutputSpec(f.get("nested")) else null
141+
return PromptField(name, kind, required, array, enumValues, enumDoc, example, instruction, nested)
142+
}
143+
144+
private fun buildRecoverSchema(c: JsonNode): RecoverSchema {
145+
val fmt = toFormat(c.get("format").asText())
146+
val root = c.get("rootName").asText()
147+
val fields = c.get("fields").map { buildFieldSpec(it) }
148+
return RecoverSchema(fmt, root, fields)
149+
}
150+
151+
private fun buildFieldSpec(f: JsonNode): FieldSpec {
152+
val name = f.get("name").asText()
153+
val kind = FieldKind.valueOf(f.get("kind").asText())
154+
val required = f.has("required") && f.get("required").asBoolean()
155+
val array = f.has("array") && f.get("array").asBoolean()
156+
if (kind == FieldKind.ENUM) {
157+
val vals: List<String>? =
158+
if (f.has("enumValues") && !f.get("enumValues").isNull) f.get("enumValues").map { it.asText() } else null
159+
return FieldSpec.enumField(name, required, vals, emptyMap<String, String>())
160+
}
161+
if (kind == FieldKind.OBJECT) {
162+
val nested = if (f.has("nested") && !f.get("nested").isNull) buildRecoverSchema(f.get("nested")) else null
163+
return FieldSpec.`object`(name, required, array, nested)
164+
}
165+
return FieldSpec.scalar(name, kind, required)
166+
}
167+
168+
private fun toFormat(f: String): Format = if ("json" == f) Format.JSON else Format.XML
169+
}

0 commit comments

Comments
 (0)