|
| 1 | +package agents_engine.generation |
| 2 | + |
| 3 | +import kotlin.test.Test |
| 4 | +import kotlin.test.assertEquals |
| 5 | +import kotlin.test.assertFalse |
| 6 | +import kotlin.test.assertNotEquals |
| 7 | +import kotlin.test.assertNotNull |
| 8 | +import kotlin.test.assertNull |
| 9 | +import kotlin.test.assertTrue |
| 10 | + |
| 11 | +// Tests for the `private object GeneratedMetaCache` inside GenerableSupport.kt. |
| 12 | +// 14 mutants (13 SURVIVED + 1 NO_COVERAGE) cluster on `load` and `tryLoad` — |
| 13 | +// the cache-and-load helper that finds `${fqn}__GeneratedSchema` companions |
| 14 | +// emitted by the KSP processor. The cache is `private`; we exercise it via |
| 15 | +// the public lookup surface (jsonSchema / toLlmDescription / constructFromMap) |
| 16 | +// on hand-crafted fixtures with deliberately-shaped __GeneratedSchema classes. |
| 17 | +// |
| 18 | +// Each fixture is a pair (Target class + sibling __GeneratedSchema object) |
| 19 | +// where the object's static final String fields drive what GeneratedMetaCache |
| 20 | +// is supposed to find or filter out. |
| 21 | +class GeneratedMetaCacheTest { |
| 22 | + |
| 23 | + // ── happy-path: clean __GeneratedSchema fills both lookups + ctor ───────── |
| 24 | + |
| 25 | + @Test |
| 26 | + fun `lookupJsonSchema returns generated JSON_SCHEMA verbatim`() { |
| 27 | + // Kills the cache-hit / tryLoad-return-Entry chain: the assertion ties |
| 28 | + // the public return value to a string that ONLY the generated companion |
| 29 | + // carries (the reflection path would emit a different schema shape). |
| 30 | + val schema = MetaCacheCleanFoo::class.jsonSchema() |
| 31 | + assertEquals(GENERATED_CLEAN_FOO_SCHEMA, schema, |
| 32 | + "generated JSON_SCHEMA must be returned byte-identical, not reflection-built: '$schema'") |
| 33 | + } |
| 34 | + |
| 35 | + @Test |
| 36 | + fun `toLlmDescription returns generated LLM_DESCRIPTION verbatim`() { |
| 37 | + val desc = MetaCacheCleanFoo::class.toLlmDescription() |
| 38 | + assertEquals(GENERATED_CLEAN_FOO_DESC, desc, |
| 39 | + "generated LLM_DESCRIPTION must be returned verbatim: '$desc'") |
| 40 | + } |
| 41 | + |
| 42 | + @Test |
| 43 | + fun `constructFromMap dispatches to generated constructor when present`() { |
| 44 | + // The fixture's constructFromMap sets a sentinel flag we read back to |
| 45 | + // prove the GENERATED path executed (vs reflection-fallback). |
| 46 | + MetaCacheCleanFoo__GeneratedSchema.lastCtorInvoked = false |
| 47 | + val out = MetaCacheCleanFoo::class.constructFromMap(mapOf("dummy" to 1)) |
| 48 | + assertNotNull(out) |
| 49 | + assertTrue(MetaCacheCleanFoo__GeneratedSchema.lastCtorInvoked, |
| 50 | + "generated constructFromMap must have run (sentinel flag)") |
| 51 | + } |
| 52 | + |
| 53 | + // ── cache: second call returns same value (no re-parse) ─────────────────── |
| 54 | + |
| 55 | + @Test |
| 56 | + fun `lookupJsonSchema cache returns same value on repeat call`() { |
| 57 | + // Kills the L 69 cache-hit branch indirectly: if the cache check were |
| 58 | + // negated, the second call would re-walk the fields, but still return |
| 59 | + // the same content (so equality alone doesn't kill it). We assert |
| 60 | + // referential equality on the returned String — generated const-vals |
| 61 | + // come back as the SAME String instance via field.get(null), so the |
| 62 | + // intern() / cache-hit path yields `===` equality across calls. |
| 63 | + val a = MetaCacheCleanFoo::class.jsonSchema() |
| 64 | + val b = MetaCacheCleanFoo::class.jsonSchema() |
| 65 | + assertTrue(a === b, "repeat lookup must return the same String instance (cache hit + const pool)") |
| 66 | + } |
| 67 | + |
| 68 | + // ── empty fixture: __GeneratedSchema exists but has nothing useful ──────── |
| 69 | + |
| 70 | + @Test |
| 71 | + fun `lookup falls through to reflection when __GeneratedSchema has no constants and no constructor`() { |
| 72 | + // Kills L 103 `if (constants.isEmpty() && constructor == null) null`: |
| 73 | + // both empty → tryLoad returns null → load caches MISS → public |
| 74 | + // lookup falls through to reflection. |
| 75 | + val schema = MetaCacheEmptyBar::class.jsonSchema() |
| 76 | + // Reflection-built schema includes "type":"object" with declared field name. |
| 77 | + assertTrue(schema.contains(""""type":"object""""), |
| 78 | + "empty generated schema must fall through to reflection: $schema") |
| 79 | + assertTrue(schema.contains("greeting"), |
| 80 | + "reflection schema must include the actual field 'greeting': $schema") |
| 81 | + } |
| 82 | + |
| 83 | + // ── type filter: non-String JSON_SCHEMA is skipped ──────────────────────── |
| 84 | + |
| 85 | + @Test |
| 86 | + fun `non-String fields are filtered out of the constants map`() { |
| 87 | + // Kills L 88 `field.type == String::class.java` negation: the fixture |
| 88 | + // has `const val JSON_SCHEMA: Int = 42` — a static final NON-String. |
| 89 | + // The type guard must reject it; jsonSchema() should NOT return "42" |
| 90 | + // and must fall through to the reflection path. |
| 91 | + val schema = MetaCacheBadTypeBaz::class.jsonSchema() |
| 92 | + assertFalse(schema.contains("42"), |
| 93 | + "Int-typed JSON_SCHEMA must be filtered by the type guard: $schema") |
| 94 | + // LLM_DESCRIPTION (a real String const) IS still loaded. |
| 95 | + assertEquals(GENERATED_BAD_TYPE_DESC, MetaCacheBadTypeBaz::class.toLlmDescription(), |
| 96 | + "co-located String const must still load even when sibling Int is filtered") |
| 97 | + } |
| 98 | + |
| 99 | + // ── final filter: @JvmField var (non-final) is skipped ──────────────────── |
| 100 | + |
| 101 | + @Test |
| 102 | + fun `non-final static String fields are filtered out`() { |
| 103 | + // Kills L 87 `Modifier.isFinal(mods)` negation: @JvmField var produces |
| 104 | + // a static-but-NOT-final field. The final-guard must reject it. |
| 105 | + val schema = MetaCacheMutableQux::class.jsonSchema() |
| 106 | + assertFalse(schema.contains("should-be-filtered"), |
| 107 | + "non-final @JvmField var must be filtered: $schema") |
| 108 | + // The const val LLM_DESCRIPTION (static final String) still loads. |
| 109 | + assertEquals(GENERATED_MUTABLE_QUX_DESC, MetaCacheMutableQux::class.toLlmDescription()) |
| 110 | + } |
| 111 | + |
| 112 | + // ── null-value filter: @JvmField val: String? = null is skipped ────────── |
| 113 | + |
| 114 | + @Test |
| 115 | + fun `static final String fields with null value are skipped from constants map`() { |
| 116 | + // Kills L 91/92 `if (value != null) constants[field.name] = value`: |
| 117 | + // a String? field whose value is null must NOT pollute the constants |
| 118 | + // map. The lookup falls through to reflection. |
| 119 | + val schema = MetaCacheNullValueQuux::class.jsonSchema() |
| 120 | + // If the null-value guard were broken, constants["JSON_SCHEMA"] would |
| 121 | + // be set to "" or NPE on the cast; either way the assertion below |
| 122 | + // would fail vs the reflection schema. |
| 123 | + assertTrue(schema.contains(""""type":"object""""), |
| 124 | + "null-valued JSON_SCHEMA must be ignored; reflection schema returned: $schema") |
| 125 | + } |
| 126 | + |
| 127 | + // ── static filter: instance (non-static) fields are skipped ─────────────── |
| 128 | + |
| 129 | + @Test |
| 130 | + fun `instance (non-static) fields are filtered out by the static guard`() { |
| 131 | + // Kills L 86 `Modifier.isStatic(mods)` negation. The fixture's |
| 132 | + // __GeneratedSchema is a Kotlin `class` (not object), so its primary |
| 133 | + // ctor parameter becomes a non-static instance field. Class.forName |
| 134 | + // succeeds but the field is filtered, leaving constants empty + |
| 135 | + // constructor null → MISS cached. |
| 136 | + val schema = MetaCacheInstanceFields::class.jsonSchema() |
| 137 | + assertFalse(schema.contains("should-be-filtered"), |
| 138 | + "non-static instance field must be filtered: $schema") |
| 139 | + assertTrue(schema.contains(""""type":"object""""), |
| 140 | + "expected reflection fallback after filter rejected everything: $schema") |
| 141 | + } |
| 142 | + |
| 143 | + // ── constructor-only fixture: kills the L 103 constructor.isEmpty side ──── |
| 144 | + |
| 145 | + @Test |
| 146 | + fun `__GeneratedSchema with only constructFromMap (no constants) still loads via Entry`() { |
| 147 | + // Kills L 103: `constants.isEmpty() && constructor == null`. With |
| 148 | + // constants empty AND constructor present, the `&&` short-circuits |
| 149 | + // to false → Entry returned (not null) → MISS not cached. |
| 150 | + // Verify by constructing a value through the public API. |
| 151 | + MetaCacheCtorOnly__GeneratedSchema.lastCtorInvoked = false |
| 152 | + val out = MetaCacheCtorOnly::class.constructFromMap(emptyMap<String, Any?>()) |
| 153 | + assertNotNull(out, "constructor-only fixture must produce an instance") |
| 154 | + assertTrue(MetaCacheCtorOnly__GeneratedSchema.lastCtorInvoked, |
| 155 | + "generated constructFromMap must have run") |
| 156 | + // ALSO: jsonSchema lookup falls through to reflection because constants empty. |
| 157 | + val schema = MetaCacheCtorOnly::class.jsonSchema() |
| 158 | + assertTrue(schema.contains(""""type":"object""""), |
| 159 | + "no JSON_SCHEMA const → fall through to reflection: $schema") |
| 160 | + } |
| 161 | + |
| 162 | + // ── miss: class without any __GeneratedSchema at all ───────────────────── |
| 163 | + |
| 164 | + @Test |
| 165 | + fun `class without __GeneratedSchema falls through to reflection (ClassNotFoundException path)`() { |
| 166 | + // Kills the `catch (_: ClassNotFoundException)` early-return + the |
| 167 | + // L 70 `?: MISS` fallback. MetaCacheNoCompanion has NO generated |
| 168 | + // sibling, so Class.forName throws and tryLoad returns null. |
| 169 | + val desc = MetaCacheNoCompanion::class.toLlmDescription() |
| 170 | + // Reflection-built description includes the class name. |
| 171 | + assertTrue(desc.contains("MetaCacheNoCompanion"), |
| 172 | + "reflection fallback must run when no __GeneratedSchema exists: $desc") |
| 173 | + // Repeat call exercises the cached-MISS path (L 69 hit). |
| 174 | + val again = MetaCacheNoCompanion::class.toLlmDescription() |
| 175 | + assertEquals(desc, again, "cached MISS must yield the same reflection result") |
| 176 | + } |
| 177 | + |
| 178 | + // ── distinct return: generated and reflection paths differ ─────────────── |
| 179 | + |
| 180 | + @Test |
| 181 | + fun `generated JSON_SCHEMA differs from reflection-built schema (proves cache short-circuits)`() { |
| 182 | + // Defensive: if the cache return were skipped entirely, the public |
| 183 | + // method would still produce a valid (reflection) schema — but a |
| 184 | + // DIFFERENT one. Comparing them proves the cache fired. |
| 185 | + val generated = MetaCacheCleanFoo::class.jsonSchema() |
| 186 | + // The reflection path on the same class would compute something |
| 187 | + // structurally different — `{"type":"object","properties":{...}}` |
| 188 | + // — not our hand-crafted GENERATED_CLEAN_FOO_SCHEMA sentinel. |
| 189 | + assertNotEquals("""{"type":"object","properties":{},"additionalProperties":false}""", generated, |
| 190 | + "generated path must not collapse to the trivial empty-object schema") |
| 191 | + assertEquals(GENERATED_CLEAN_FOO_SCHEMA, generated) |
| 192 | + } |
| 193 | +} |
| 194 | + |
| 195 | +// ───────────────────────────────────────────────────────────────────────────── |
| 196 | +// Fixtures — top-level so their FQNs are predictable for the |
| 197 | +// `${fqn}__GeneratedSchema` lookup convention. |
| 198 | +// ───────────────────────────────────────────────────────────────────────────── |
| 199 | + |
| 200 | +// Sentinel constants used in assertions, defined once. |
| 201 | +internal const val GENERATED_CLEAN_FOO_SCHEMA = """{"type":"object","properties":{"clean":{"type":"boolean"}}}""" |
| 202 | +internal const val GENERATED_CLEAN_FOO_DESC = "## CleanFoo (generated)" |
| 203 | +internal const val GENERATED_BAD_TYPE_DESC = "## BadType (generated, mixed-type fields)" |
| 204 | +internal const val GENERATED_MUTABLE_QUX_DESC = "## MutableQux (generated, non-final sibling)" |
| 205 | + |
| 206 | +// FIXTURE 1: clean — both constants + a constructor. |
| 207 | +class MetaCacheCleanFoo |
| 208 | +object MetaCacheCleanFoo__GeneratedSchema { |
| 209 | + const val JSON_SCHEMA: String = GENERATED_CLEAN_FOO_SCHEMA |
| 210 | + const val LLM_DESCRIPTION: String = GENERATED_CLEAN_FOO_DESC |
| 211 | + |
| 212 | + // Sentinel so the test can verify the GENERATED ctor ran (vs reflection). |
| 213 | + @JvmField var lastCtorInvoked: Boolean = false |
| 214 | + |
| 215 | + @JvmStatic |
| 216 | + fun constructFromMap(@Suppress("UNUSED_PARAMETER") fields: Map<*, Any?>): MetaCacheCleanFoo { |
| 217 | + lastCtorInvoked = true |
| 218 | + return MetaCacheCleanFoo() |
| 219 | + } |
| 220 | +} |
| 221 | + |
| 222 | +// FIXTURE 2: empty — companion exists but has no string consts and no ctor. |
| 223 | +data class MetaCacheEmptyBar(val greeting: String = "hi") |
| 224 | +object MetaCacheEmptyBar__GeneratedSchema { |
| 225 | + // Intentionally empty. INSTANCE field is static final but type ≠ String. |
| 226 | +} |
| 227 | + |
| 228 | +// FIXTURE 3: non-String const → must be filtered by the type guard. |
| 229 | +class MetaCacheBadTypeBaz |
| 230 | +object MetaCacheBadTypeBaz__GeneratedSchema { |
| 231 | + const val JSON_SCHEMA: Int = 42 // wrong type — filtered |
| 232 | + const val LLM_DESCRIPTION: String = GENERATED_BAD_TYPE_DESC |
| 233 | +} |
| 234 | + |
| 235 | +// FIXTURE 4: non-final var → must be filtered by the final guard. |
| 236 | +class MetaCacheMutableQux |
| 237 | +object MetaCacheMutableQux__GeneratedSchema { |
| 238 | + @JvmField var JSON_SCHEMA: String = "should-be-filtered" // non-final |
| 239 | + const val LLM_DESCRIPTION: String = GENERATED_MUTABLE_QUX_DESC |
| 240 | +} |
| 241 | + |
| 242 | +// FIXTURE 5: static final String? with null value → skipped by value-null guard. |
| 243 | +data class MetaCacheNullValueQuux(val payload: String = "p") |
| 244 | +object MetaCacheNullValueQuux__GeneratedSchema { |
| 245 | + @JvmField val JSON_SCHEMA: String? = null // static final String, value=null |
| 246 | +} |
| 247 | + |
| 248 | +// FIXTURE 6: non-static instance fields → filtered by the static guard. |
| 249 | +// Using a Kotlin `class` (not object) for the __GeneratedSchema means the |
| 250 | +// primary-constructor backed field is an INSTANCE field, not static. |
| 251 | +data class MetaCacheInstanceFields(val text: String = "default") |
| 252 | +@Suppress("unused") |
| 253 | +class MetaCacheInstanceFields__GeneratedSchema { |
| 254 | + @JvmField val JSON_SCHEMA: String = "should-be-filtered" // instance field, not static |
| 255 | +} |
| 256 | + |
| 257 | +// FIXTURE 7: constructor only, no string consts. |
| 258 | +class MetaCacheCtorOnly |
| 259 | +object MetaCacheCtorOnly__GeneratedSchema { |
| 260 | + @JvmField var lastCtorInvoked: Boolean = false |
| 261 | + |
| 262 | + @JvmStatic |
| 263 | + fun constructFromMap(@Suppress("UNUSED_PARAMETER") fields: Map<*, Any?>): MetaCacheCtorOnly { |
| 264 | + lastCtorInvoked = true |
| 265 | + return MetaCacheCtorOnly() |
| 266 | + } |
| 267 | +} |
| 268 | + |
| 269 | +// FIXTURE 8: no __GeneratedSchema sibling at all → ClassNotFoundException path. |
| 270 | +data class MetaCacheNoCompanion(val n: Int = 0) |
| 271 | +// (intentionally NO MetaCacheNoCompanion__GeneratedSchema) |
0 commit comments