Skip to content

Commit f8bbbb9

Browse files
Skobeltsynclaude
andcommitted
test(#1975): boundary + dispatch + sealed-schema tests
Second batch for #1975. After first batch (4b2f564) dropped the cluster 65 → 41, PIT identified the residual mutants as concentrated in: - coerceToInt/coerceToLong Double/Float branch boundaries - coerceValue private dispatch (Float type, raw-List early-return) - hasGenerableAnnotation cache vs reflection short-circuits - variantJsonSchema / sealedJsonSchema / sealedLlmDescription — the sealed-hierarchy schema-generation surfaces Two new files: **CoerceBoundaryAndDispatchTest** (20 tests) — exact-boundary tests for the Double/Float branches inside coerceToInt + coerceToLong (lines 590, 596, 677, 683). Plus coerceValue dispatch through constructFromMap with new @generable wrappers (IntWrap, LongWrap, DoubleWrap, FloatWrap, RawListWrap). Plus hasGenerableAnnotation tests with @generable and non-@generable classes (PlainGenerable vs String). **SealedSchemaGenerationTest** (11 tests) — uses a 3-variant ReviewDecision sealed hierarchy (Approved + Rejected + Deferred) to exercise: - sealedJsonSchema separator placement (`if (i > 0) append(",")` — kills the ConditionalsBoundary mutant that would produce `[,{...},{...}]`) - variantJsonSchema type discriminator emission (const string per variant) - variantJsonSchema required-list filter (nullable fields like Approved.notes must NOT appear in required; non-nullable must appear) - variantJsonSchema null-ctor handling (Deferred data-object has only the type discriminator in properties + required) - sealedLlmDescription optional-description guard (with and without @generable description text) - toLlmDescription sealed-vs-data-class dispatch - "Choose one of the following variants:" intro present for sealed, absent for data class 31 new tests total, all green. Expected PIT impact: GenerableSupportKt cluster drops from 41 → low teens (target was <25 per #1975 acceptance). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4b2f564 commit f8bbbb9

2 files changed

Lines changed: 351 additions & 0 deletions

File tree

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
package agents_engine.generation
2+
3+
import kotlin.test.Test
4+
import kotlin.test.assertEquals
5+
import kotlin.test.assertNotNull
6+
import kotlin.test.assertNull
7+
import kotlin.test.assertTrue
8+
9+
// Second batch for #1975. Focuses on the residual mutants
10+
// CoerceHelpersBranchTest didn't reach:
11+
//
12+
// - coerceToInt:590, 596 — Double/Float branch boundary at Int.MAX_VALUE.toDouble()
13+
// (ConditionalsBoundary mutants)
14+
// - coerceToLong:677, 683 — Float branch boundary at Long.MAX_VALUE.toDouble()
15+
// - coerceValue:561 — Float dispatch path (private — via constructFromMap)
16+
// - coerceValue:565 — List-without-type-parameter early-return (private)
17+
// - hasGenerableAnnotation:619, 620 — KSP cache-hit short-circuit
18+
//
19+
// Tests exercise these via constructFromMap with @Generable wrappers — the
20+
// helpers are private, so we drive them through the public surface.
21+
22+
@Generable("Int wrapper") data class IntWrap(val n: Int)
23+
@Generable("Long wrapper") data class LongWrap(val n: Long)
24+
@Generable("Double wrapper") data class DoubleWrap(val v: Double)
25+
@Generable("Float wrapper") data class FloatWrap(val v: Float)
26+
@Generable("Raw list wrapper") data class RawListWrap(val items: List<*>)
27+
@Generable("Plain marker") data class PlainGenerable(val name: String)
28+
29+
class CoerceBoundaryAndDispatchTest {
30+
31+
// ── coerceToInt — Double branch boundary (line 590) ───────────────────────
32+
33+
@Test fun `coerceInt accepts Int_MAX_VALUE_toDouble exactly (Double branch)`() {
34+
// Kills ConditionalsBoundaryMutator on line 590:
35+
// if (n > Int.MAX_VALUE.toDouble()) return null
36+
// The mutant flips `>` to `>=`. The mutated condition would reject
37+
// n == Int.MAX_VALUE.toDouble(). We assert it ACCEPTS this exact value.
38+
val result = IntWrap::class.constructFromMap(mapOf("n" to Int.MAX_VALUE.toDouble()))
39+
assertNotNull(result, "Int.MAX_VALUE as Double exactly must coerce successfully")
40+
assertEquals(Int.MAX_VALUE, result!!.n)
41+
}
42+
43+
@Test fun `coerceInt accepts Int_MIN_VALUE_toDouble exactly (Double branch)`() {
44+
// Mirror — the lower boundary mutant.
45+
val result = IntWrap::class.constructFromMap(mapOf("n" to Int.MIN_VALUE.toDouble()))
46+
assertNotNull(result)
47+
assertEquals(Int.MIN_VALUE, result!!.n)
48+
}
49+
50+
@Test fun `coerceInt rejects just-above Int_MAX as Double`() {
51+
// Anchors the boundary from the other side.
52+
val result = IntWrap::class.constructFromMap(
53+
mapOf("n" to Int.MAX_VALUE.toDouble() + 1.0)
54+
)
55+
assertNull(result, "Int.MAX_VALUE + 1.0 (Double) must reject")
56+
}
57+
58+
@Test fun `coerceInt rejects just-below Int_MIN as Double`() {
59+
val result = IntWrap::class.constructFromMap(
60+
mapOf("n" to Int.MIN_VALUE.toDouble() - 1.0)
61+
)
62+
assertNull(result, "Int.MIN_VALUE - 1.0 (Double) must reject")
63+
}
64+
65+
// ── coerceToInt — Float branch boundary (line 596) ────────────────────────
66+
67+
@Test fun `coerceInt Float branch accepts whole value within range`() {
68+
// Kills ConditionalsBoundaryMutator on line 596 (Float branch's range check).
69+
// Float can't exactly represent Int.MAX_VALUE; use a value safely within
70+
// the Float's precision so the equality with floor(n) holds.
71+
val result = IntWrap::class.constructFromMap(mapOf("n" to 1_000_000.0f))
72+
assertNotNull(result)
73+
assertEquals(1_000_000, result!!.n)
74+
}
75+
76+
@Test fun `coerceInt Float branch rejects huge value above Int_MAX`() {
77+
val result = IntWrap::class.constructFromMap(mapOf("n" to 1e10f))
78+
assertNull(result, "1e10 as Float exceeds Int.MAX_VALUE → reject")
79+
}
80+
81+
@Test fun `coerceInt Float branch rejects huge negative value below Int_MIN`() {
82+
val result = IntWrap::class.constructFromMap(mapOf("n" to -1e10f))
83+
assertNull(result, "-1e10 as Float below Int.MIN_VALUE → reject")
84+
}
85+
86+
// ── coerceToLong — Float branch boundary (lines 677, 683) ─────────────────
87+
88+
@Test fun `coerceLong Float branch accepts whole value within range`() {
89+
val result = LongWrap::class.constructFromMap(mapOf("n" to 1_000_000.0f))
90+
assertNotNull(result)
91+
assertEquals(1_000_000L, result!!.n)
92+
}
93+
94+
@Test fun `coerceLong Double branch boundary at large value within range`() {
95+
// 1e15 is exactly representable as Double (within mantissa precision)
96+
// AND well within Long range — covers the upper-branch happy path.
97+
val result = LongWrap::class.constructFromMap(mapOf("n" to 1e15))
98+
assertNotNull(result)
99+
assertEquals(1_000_000_000_000_000L, result!!.n)
100+
}
101+
102+
@Test fun `coerceLong Double branch rejects out-of-Long-range`() {
103+
// 1e20 exceeds Long.MAX_VALUE (~9.2e18). Kills the boundary check.
104+
val result = LongWrap::class.constructFromMap(mapOf("n" to 1e20))
105+
assertNull(result)
106+
}
107+
108+
@Test fun `coerceLong Float branch rejects out-of-Long-range`() {
109+
val result = LongWrap::class.constructFromMap(mapOf("n" to 1e20f))
110+
assertNull(result)
111+
}
112+
113+
// ── coerceValue — Float dispatch path (line 561) ──────────────────────────
114+
115+
@Test fun `coerceValue Float type accepts Number-as-Float`() {
116+
// Drives coerceValue line 561: Float::class -> (value as? Number)?.toFloat()
117+
val result = FloatWrap::class.constructFromMap(mapOf("v" to 3.14))
118+
assertNotNull(result)
119+
assertEquals(3.14f, result!!.v, 1e-3f)
120+
}
121+
122+
@Test fun `coerceValue Float type rejects non-Number`() {
123+
// Drives the `(value as? Number)?` short-circuit at line 561.
124+
// The mutant that strips `as? Number` would NPE; current code returns null.
125+
val result = FloatWrap::class.constructFromMap(mapOf("v" to "3.14"))
126+
assertNull(result, "String value must not silently coerce to Float")
127+
}
128+
129+
@Test fun `coerceValue Double type accepts Number-as-Double`() {
130+
val result = DoubleWrap::class.constructFromMap(mapOf("v" to 42))
131+
assertNotNull(result)
132+
assertEquals(42.0, result!!.v, 1e-9)
133+
}
134+
135+
@Test fun `coerceValue Double type rejects non-Number`() {
136+
val result = DoubleWrap::class.constructFromMap(mapOf("v" to "42.0"))
137+
assertNull(result)
138+
}
139+
140+
// ── coerceValue — List-without-type-parameter (line 565) ──────────────────
141+
142+
@Test fun `coerceValue raw List (star-projected) returns items as-is`() {
143+
// Line 565: `val elementType = type.arguments.firstOrNull()?.type ?: return items`
144+
// The early return happens when the List has no type argument (raw or
145+
// star-projected). Test asserts items are passed through untouched.
146+
val result = RawListWrap::class.constructFromMap(
147+
mapOf("items" to listOf("a", 1, true, null))
148+
)
149+
assertNotNull(result)
150+
assertEquals(4, result!!.items.size)
151+
assertEquals("a", result.items[0])
152+
assertEquals(1, result.items[1])
153+
assertEquals(true, result.items[2])
154+
assertNull(result.items[3])
155+
}
156+
157+
@Test fun `coerceValue raw List with empty input returns empty list`() {
158+
val result = RawListWrap::class.constructFromMap(mapOf("items" to emptyList<Any?>()))
159+
assertNotNull(result)
160+
assertEquals(0, result!!.items.size)
161+
}
162+
163+
@Test fun `coerceValue raw List rejects non-List value`() {
164+
// Same line 565 — preceding `value as? List<*> ?: return null` is the
165+
// typed-cast guard. Kills the mutant that swaps `?:` for non-null assert.
166+
val result = RawListWrap::class.constructFromMap(mapOf("items" to "not a list"))
167+
assertNull(result)
168+
}
169+
170+
// ── hasGenerableAnnotation (lines 619, 620) ───────────────────────────────
171+
172+
@Test fun `hasGenerableAnnotation true for @Generable class via reflection fallback`() {
173+
// Line 620: `if (GeneratedMetaCache.lookupLlmDescription(this) != null) return true`
174+
// (and the reflection fallback after). Kills the boolean-return mutant.
175+
assertTrue(
176+
PlainGenerable::class.hasGenerableAnnotation(),
177+
"PlainGenerable carries @Generable; probe must return true",
178+
)
179+
}
180+
181+
@Test fun `hasGenerableAnnotation false for non-Generable class`() {
182+
// Kills mutant that hardcodes true.
183+
assertEquals(
184+
false,
185+
String::class.hasGenerableAnnotation(),
186+
"String has no @Generable; probe must return false",
187+
)
188+
}
189+
}
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
package agents_engine.generation
2+
3+
import kotlin.test.Test
4+
import kotlin.test.assertEquals
5+
import kotlin.test.assertTrue
6+
import kotlin.test.assertFalse
7+
8+
// Third batch for #1975. Targets the sealed-hierarchy schema/description
9+
// generators that the existing GenerableSupportTest doesn't exercise deeply:
10+
//
11+
// - variantJsonSchema:255, 260, 759 — 7 mutants on the per-variant emitter
12+
// - sealedJsonSchema:243 — `if (i > 0) append(",")` separator
13+
// - sealedLlmDescription:167 — `if (genDescription.isNotEmpty())` guard
14+
// - toLlmDescription:138 — sealed-dispatch in the public entry
15+
//
16+
// These are the "byte-identical to runtime" surfaces #1975 calls out as
17+
// load-bearing for prompt-cache determinism (and for KSP parity).
18+
19+
@Generable("A decision a user can make")
20+
sealed interface ReviewDecision {
21+
@Generable("Approve the request") data class Approved(val by: String, val notes: String?) : ReviewDecision
22+
@Generable("Reject with a reason") data class Rejected(val by: String, val reason: String) : ReviewDecision
23+
@Generable("Defer to later — no fields") data object Deferred : ReviewDecision
24+
}
25+
26+
@Generable
27+
sealed interface UndescribedRoot {
28+
@Generable data class Variant(val n: Int) : UndescribedRoot
29+
}
30+
31+
@Generable("Empty root")
32+
sealed interface EmptyRoot
33+
34+
class SealedSchemaGenerationTest {
35+
36+
// ── sealedJsonSchema separator + variant ordering (line 243) ──────────────
37+
38+
@Test fun `sealed JSON schema wraps oneOf around all variants`() {
39+
val schema = ReviewDecision::class.jsonSchema()
40+
assertTrue(schema.startsWith("""{"oneOf":["""), "must lead with oneOf array")
41+
assertTrue(schema.endsWith("]}"), "must close with array + outer brace")
42+
}
43+
44+
@Test fun `sealed JSON schema places comma BETWEEN variants, not before first or after last`() {
45+
// Kills ConditionalsBoundaryMutator on line 243: `if (i > 0) append(",")`
46+
// Mutant flips `>` to `>=` which would prepend a comma before variant 0
47+
// — `[,{"type":...},{"type":...}]` — invalid JSON.
48+
val schema = ReviewDecision::class.jsonSchema()
49+
assertFalse(schema.contains("[,"), "leading comma is broken JSON: $schema")
50+
assertFalse(schema.contains(",]"), "trailing comma is broken JSON: $schema")
51+
// Three variants → exactly two commas between variant objects.
52+
val variantCommas = Regex("\\},\\{").findAll(schema).count()
53+
assertEquals(2, variantCommas, "ReviewDecision has 3 variants → exactly 2 inter-variant commas, got $variantCommas in $schema")
54+
}
55+
56+
// ── variantJsonSchema: type discriminator + properties (lines 255, 260) ──
57+
58+
@Test fun `each variant carries type discriminator with const matching simpleName`() {
59+
// variantJsonSchema:254 emits the const string. Kills mutants that drop
60+
// the const or use a wrong source.
61+
val schema = ReviewDecision::class.jsonSchema()
62+
assertTrue(
63+
schema.contains(""""type":{"type":"string","const":"Approved"}"""),
64+
"missing Approved type discriminator in: $schema",
65+
)
66+
assertTrue(schema.contains(""""const":"Rejected""""))
67+
assertTrue(schema.contains(""""const":"Deferred""""))
68+
}
69+
70+
@Test fun `variant properties include all primary-ctor params`() {
71+
// variantJsonSchema:255 forEach loop emits property entries.
72+
// The forEach is mutated by `negated conditional` (skipping iterations).
73+
// Three Approved params expected: type (discriminator), by, notes.
74+
val schema = ReviewDecision::class.jsonSchema()
75+
// The "Approved" variant should reference "by" and "notes" in its properties.
76+
val approvedFragment = schema.substringAfter(""""const":"Approved"""").substringBefore("""{"type":"object"""")
77+
assertTrue(approvedFragment.contains(""""by"""), "Approved schema missing 'by' field")
78+
assertTrue(approvedFragment.contains(""""notes"""), "Approved schema missing 'notes' field")
79+
}
80+
81+
@Test fun `required list includes type and only non-nullable non-optional params`() {
82+
// variantJsonSchema:260 filter — kills the "skip filter" mutant
83+
// (would include nullable fields like Approved.notes in required).
84+
val schema = ReviewDecision::class.jsonSchema()
85+
// Approved.notes is nullable → must NOT be in its variant's required list.
86+
// Approved.by is non-null → MUST be in its required list.
87+
val approvedFragment = schema.substringAfter(""""const":"Approved"""")
88+
.substringBefore("""{"type":"object"""")
89+
val requiredSection = approvedFragment.substringAfter(""""required":[""", "").substringBefore("""]""", "")
90+
assertTrue(requiredSection.contains(""""type""""), "type discriminator must always be required")
91+
assertTrue(requiredSection.contains(""""by""""), "non-nullable 'by' must be required")
92+
assertFalse(requiredSection.contains(""""notes""""), "nullable 'notes' must NOT be required, got: $requiredSection")
93+
}
94+
95+
@Test fun `data-object variant has only the type discriminator and required`() {
96+
// Deferred has no ctor params. variantJsonSchema:251 — if ctor is null,
97+
// no properties are emitted beyond the discriminator.
98+
// sealedSubclasses ordering is NOT source-order; can't substringBefore("]}")
99+
// across the whole schema. Instead, assert the immediate sequence after
100+
// the Deferred discriminator: `}},"required":["type"],"additionalProperties":false}`
101+
// (no comma → no extra property, no comma in required → no extra required).
102+
val schema = ReviewDecision::class.jsonSchema()
103+
assertTrue(
104+
schema.contains(""""const":"Deferred"}},"required":["type"],"additionalProperties":false}"""),
105+
"Deferred should have no ctor params: properties should hold ONLY the type discriminator, " +
106+
"required list should hold ONLY \"type\". Actual schema: $schema",
107+
)
108+
}
109+
110+
// ── sealedLlmDescription: optional-description guard (line 167) ──────────
111+
112+
@Test fun `sealed description includes Generable description when present`() {
113+
// Line 167: `if (genDescription.isNotEmpty())` — mutant flips to always-true
114+
// or always-false. Test asserts the description text appears.
115+
val desc = ReviewDecision::class.toLlmDescription()
116+
assertTrue(
117+
desc.contains("A decision a user can make"),
118+
"Generable description must appear in sealed output: $desc",
119+
)
120+
}
121+
122+
@Test fun `sealed description omits empty-description gap`() {
123+
// Same line 167, other side. UndescribedRoot has no description text
124+
// → no blank line + description block; just `## UndescribedRoot` followed by
125+
// the variants section.
126+
val desc = UndescribedRoot::class.toLlmDescription()
127+
assertTrue(desc.startsWith("## UndescribedRoot"))
128+
// Should NOT have a `description` text — only the variants intro.
129+
assertTrue(desc.contains("Choose one of the following variants:"))
130+
}
131+
132+
@Test fun `sealed description lists all variants`() {
133+
val desc = ReviewDecision::class.toLlmDescription()
134+
// Each variant should be enumerated by name in the variants section.
135+
assertTrue(desc.contains("Approved"), "missing Approved variant in: $desc")
136+
assertTrue(desc.contains("Rejected"))
137+
assertTrue(desc.contains("Deferred"))
138+
}
139+
140+
// ── toLlmDescription: sealed dispatch (line 138) ─────────────────────────
141+
142+
@Test fun `sealed root routes to sealedLlmDescription not dataClassLlmDescription`() {
143+
// toLlmDescription:137 `if (isSealed) sealedLlmDescription() else dataClassLlmDescription()`.
144+
// Mutant negates the conditional → routes sealed to dataClass path,
145+
// which would NOT contain "Choose one of the following variants:".
146+
val desc = ReviewDecision::class.toLlmDescription()
147+
assertTrue(
148+
desc.contains("Choose one of the following variants:"),
149+
"sealed dispatch broken — must include variants intro: $desc",
150+
)
151+
}
152+
153+
@Test fun `non-sealed data class routes to dataClassLlmDescription not sealedLlmDescription`() {
154+
// The other side of the dispatch.
155+
val desc = IntWrap::class.toLlmDescription()
156+
assertFalse(
157+
desc.contains("Choose one of the following variants:"),
158+
"non-sealed routed to sealed path — wrong dispatch: $desc",
159+
)
160+
assertTrue(desc.startsWith("## IntWrap"))
161+
}
162+
}

0 commit comments

Comments
 (0)