001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.juneau.bct.junit;
018
019import static org.apache.juneau.commons.utils.Shorts.*;
020
021import java.lang.reflect.*;
022import java.util.*;
023
024import org.apache.juneau.commons.inject.*;
025import org.junit.jupiter.api.extension.*;
026
027/**
028 * JUnit 5 extension that discovers {@link TestBean @TestBean}-annotated members on a test class and exposes them as
029 * a {@link TestBeanStore} overlay.
030 *
031 * <a id="ModeInject"></a>
032 * <h5 class='section'>Mode INJECT &mdash; fresh-instance with overrides (default):</h5>
033 * <p>
034 * The extension supports two wiring patterns.  <b>Mode INJECT</b> is the default and what every
035 * {@code @TestBean}-based test ships with out of the box.  The contract:
036 * <ul>
037 *    <li>The SUT (a {@code RestContext} via {@code MockRestClient}, a {@code Microservice}, a {@code SerializerSet},
038 *       etc.) is constructed <i>after</i> the overlay is built.  The overlay is threaded into the builder via
039 *       {@code overridingBeanStore(...)} (see {@link BeanStoreOverridable}), which
040 *       installs it in the {@code overridingParent} slot of the SUT's bean store at construction time.
041 *    <li>Because the overlay sits at tier&nbsp;1 of the resolution chain, every framework-managed bean lookup
042 *       consults it <i>before</i> any local {@code @Bean} factory or memoizer-backed default.  Mode INJECT is
043 *       therefore <i>universal</i>: all bean types are eligible for replacement, including those that subsequently
044 *       get pinned into a per-op memoizer.
045 *    <li>The overlay's lifetime is bounded by the SUT's lifetime.  For a per-test fresh SUT (the most common
046 *       shape), the overlay is discarded when the SUT goes out of scope at {@code @AfterEach}.  For a per-class
047 *       shared SUT (built once in {@code @BeforeAll}), the overlay survives until the SUT is torn down at
048 *       {@code @AfterAll}.  No explicit push/pop is required.
049 * </ul>
050 *
051 * <p>
052 * {@link TestBean @TestBean}, {@link TestBeanStore},
053 * {@link BeanStoreOverridable BeanStoreOverridable}, and the {@code overridingBeanStore(...)}
054 * setters on {@code MockRestClient.Builder}, {@code Microservice.Builder}, {@code SerializerSet.Builder},
055 * {@code ParserSet.Builder}, and {@code EncoderSet.Builder} all implement this Mode INJECT contract.
056 *
057 * <a id="ModeOverlay"></a>
058 * <h5 class='section'>Mode OVERLAY &mdash; existing-instance with push/pop:</h5>
059 * <p>
060 * <b>Mode OVERLAY</b> is the opt-in pattern for tests that hold a <i>long-lived</i> SUT (typically a
061 * {@code Microservice} booted in {@code @BeforeAll}) and want to apply per-test overlays without rebuilding it.
062 * The test calls {@link #attach(WritableBeanStore)} to point the extension at the
063 * SUT's bean store; the extension then uses
064 * {@link WritableBeanStore#pushOverlay(BeanStore)}
065 * at {@code beforeEach} / {@code beforeAll} and
066 * {@link WritableBeanStore#popOverlay(Snapshot)}
067 * at {@code afterEach} / {@code afterAll} so the SUT's bean store is restored between tests.
068 *
069 * <p>
070 * Mode OVERLAY requires {@code @TestBean(mode = Mode.OVERLAY)} on the participating annotations.  Mixing
071 * {@code Mode.INJECT} and {@code Mode.OVERLAY} on the same scope is rejected with a clear
072 * {@link IllegalStateException}.
073 *
074 * <h5 class='section'>Overlay storage:</h5>
075 * <p>
076 * The extension owns one or two {@link TestBeanStore} overlays per test execution:
077 * <ul>
078 *    <li>A <b>class-scope</b> overlay built at {@code beforeAll}, containing every {@code @TestBean(scope = CLASS)}
079 *       declared on {@code static} fields/methods of the test class hierarchy.  Lives until {@code afterAll}.
080 *    <li>A <b>method-scope</b> overlay built at {@code beforeEach}, containing every {@code @TestBean(scope = METHOD)}
081 *       declared on instance (or static, for convenience) fields/methods.  The method-scope overlay's parent is set
082 *       to the class-scope overlay (if any), so a method-scope override of {@code (Type, name)} shadows a
083 *       class-scope override of the same pair while still letting the class-scope's other entries fall through.
084 *       Lives until {@code afterEach}.
085 * </ul>
086 *
087 * <p>
088 * Wiring into the system under test is <i>explicit</i> &mdash; the extension never reflects on the test class to
089 * find a {@code RestContext} / {@code Microservice} field and never mutates the SUT after construction.  Test
090 * authors retrieve the current overlay via {@link #getStore()} and plug it into the SUT's builder hook:
091 *
092 * <p class='bjava'>
093 *    <ja>@ExtendWith</ja>(JuneauBeanStoreExtension.<jk>class</jk>)
094 *    <jk>class</jk> MyResourceTest {
095 *       <ja>@TestBean</ja> MyService <jv>mockSvc</jv> = Mockito.<jsm>mock</jsm>(MyService.<jk>class</jk>);
096 *
097 *       <ja>@Test</ja>
098 *       <jk>void</jk> aTest(TestBeanStore <jv>store</jv>) {
099 *          <jk>var</jk> <jv>client</jv> = MockRestClient.<jsm>create</jsm>(MyResource.<jk>class</jk>)
100 *             .overridingBeanStore(<jv>store</jv>)
101 *             .build();
102 *          <jv>client</jv>.get(<js>"/svc"</js>).run().assertStatus().is(200);
103 *       }
104 *    }
105 * </p>
106 *
107 * <p>
108 * Two registration styles are supported:
109 * <ul>
110 *    <li><b>Declarative:</b> {@code @ExtendWith(JuneauBeanStoreExtension.class)} on the test class.
111 *    <li><b>Programmatic:</b> a {@code @RegisterExtension} field initialized with {@link #create()}, so the test can
112 *       invoke {@link #getStore()} from within {@code @BeforeAll} / {@code @BeforeEach} bodies as well as from test
113 *       methods.
114 * </ul>
115 *
116 * <h5 class='section'>See Also:</h5><ul>
117 *    <li class='ja'>{@link TestBean} - The annotation discovered by this extension.
118 *    <li class='jc'>{@link TestBeanStore} - The overlay type the extension produces.
119 *    <li class='je'>{@link Scope} - Per-method vs per-class lifecycle selector.
120 *    <li class='je'>{@link Mode} - Mode INJECT (fresh instance) vs Mode OVERLAY (existing instance push/pop) selector.
121 * </ul>
122 *
123 * @since 10.0.0
124 */
125@SuppressWarnings({
126   "java:S3011", // setAccessible(true) is required to read package-private/private @TestBean members on user test classes
127   "java:S3776", // populateStaticOverrides() and populateInstanceOverrides() scan annotated fields/methods and modes in one pass; splitting them would obscure the override rules
128   "resource" // BeanStore lookups return Closeables owned by the JUnit lifecycle; Eclipse JDT @Owning warning is by design.
129})
130public class JuneauBeanStoreExtension implements BeforeAllCallback, AfterAllCallback, BeforeEachCallback,
131      AfterEachCallback, ParameterResolver {
132
133   private static final ExtensionContext.Namespace NAMESPACE = ExtensionContext.Namespace.create(JuneauBeanStoreExtension.class);
134   private static final String KEY_CLASS_STORE = "class-store";
135   private static final String KEY_METHOD_STORE = "method-store";
136   private static final String KEY_CLASS_SNAPSHOT = "class-snapshot";
137   private static final String KEY_METHOD_SNAPSHOT = "method-snapshot";
138
139   /**
140    * Creates a new, unconfigured extension instance for programmatic registration via {@code @RegisterExtension}.
141    *
142    * <p>
143    * Equivalent to {@code new JuneauBeanStoreExtension()} but reads more naturally at the registration site:
144    *
145    * <p class='bjava'>
146    *    <ja>@RegisterExtension</ja>
147    *    JuneauBeanStoreExtension <jv>ext</jv> = JuneauBeanStoreExtension.<jsm>create</jsm>();
148    * </p>
149    *
150    * @return A new extension instance.  Never <jk>null</jk>.
151    * @since 10.0.0
152    */
153   public static JuneauBeanStoreExtension create() {
154      return new JuneauBeanStoreExtension();
155   }
156
157   private ExtensionContext currentClassContext;
158   private ExtensionContext currentMethodContext;
159   @SuppressWarnings({
160      "java:S3077" // Publish-once reference: only plain-assigned in attach()/detach() and read-once into a local; volatile suffices for safe publication. The referenced WritableBeanStore manages its own thread-safety.
161   })
162   private volatile WritableBeanStore attached;
163
164   /**
165    * Constructor.
166    */
167   public JuneauBeanStoreExtension() { /* no-op */ }
168
169   //-----------------------------------------------------------------------------------------------------------------
170   // Mode OVERLAY attach / detach
171   //-----------------------------------------------------------------------------------------------------------------
172
173   /**
174    * Points this extension at a long-lived SUT's {@link WritableBeanStore} for Mode OVERLAY push/pop wiring.
175    *
176    * <p>
177    * Call from a {@code @BeforeAll} or {@code @BeforeEach} body when the participating {@code @TestBean} declarations
178    * use {@link Mode#OVERLAY}.  Once attached, the extension will:
179    * <ul>
180    *    <li>{@link WritableBeanStore#pushOverlay(BeanStore) push} the class-scope overlay onto the attached store at
181    *       {@code beforeAll} (if a non-empty class-scope overlay exists), and
182    *       {@link WritableBeanStore#popOverlay(Snapshot) pop} it at {@code afterAll}.
183    *    <li>Push the method-scope overlay at {@code beforeEach} and pop it at {@code afterEach}.
184    * </ul>
185    *
186    * <p>
187    * Calling {@code attach(...)} a second time before {@link #detach()} is permitted &mdash; the most recent
188    * attachment wins.  If never called, the extension stays in Mode INJECT.
189    *
190    * @param store The bean store to push/pop overlays against.  Must not be <jk>null</jk>.
191    * @return This extension, for fluent chaining.
192    * @throws NullPointerException If {@code store} is <jk>null</jk>.
193    * @since 10.0.0
194    */
195   public JuneauBeanStoreExtension attach(WritableBeanStore store) {
196      Objects.requireNonNull(store, "store must not be null");
197      attached = store;
198      return this;
199   }
200
201   /**
202    * Clears the {@link #attach(WritableBeanStore) attached} bean store reference.
203    *
204    * <p>
205    * Called automatically at {@code afterAll}; tests may also call it explicitly between scenarios.  Has no effect
206    * if the extension is not currently attached.
207    *
208    * @return This extension, for fluent chaining.
209    * @since 10.0.0
210    */
211   public JuneauBeanStoreExtension detach() {
212      attached = null;
213      return this;
214   }
215
216   /**
217    * Returns the {@link #attach(WritableBeanStore) attached} bean store, or empty if none.
218    *
219    * <p>
220    * Surface for diagnostic / testability purposes &mdash; the push/pop lifecycle uses the field directly.
221    *
222    * @return The attached store, or {@link Optional#empty()} if not currently attached.
223    * @since 10.0.0
224    */
225   public Optional<WritableBeanStore> getAttachedStore() {
226      return o(attached);
227   }
228
229   /**
230    * Returns the current effective overlay &mdash; method-scope if a test method is executing, otherwise the
231    * class-scope overlay.
232    *
233    * <p>
234    * This is the accessor most tests plug into builder hooks such as
235    * {@code MockRestClient.Builder.overridingBeanStore(...)}.
236    *
237    * @return The current overlay.  Never <jk>null</jk>.
238    * @throws IllegalStateException If no class-scope or method-scope overlay is currently active (e.g. the accessor
239    *    is called from a {@code @BeforeAll} body and the extension is not yet bound to a test class).
240    * @since 10.0.0
241    */
242   public TestBeanStore getStore() {
243      var s = currentMethodStore();
244      if (s != null)
245         return s;
246      s = currentClassStore();
247      if (s != null)
248         return s;
249      throw new IllegalStateException("JuneauBeanStoreExtension is not currently bound to a test class or test method.");
250   }
251
252   /**
253    * Returns the overlay for the specified scope, if one is currently active.
254    *
255    * <p>
256    * Allows a test to reach past the current effective overlay and inspect a specific lifecycle layer.  For
257    * example, a test running under both a class-scope and a method-scope overlay can use
258    * {@code getStore(Scope.CLASS)} to retrieve the shared per-class layer directly.
259    *
260    * @param scope The lifecycle scope to query.  Must not be <jk>null</jk>.
261    * @return The overlay for that scope, or {@link Optional#empty()} if no such overlay is active.
262    * @since 10.0.0
263    */
264   public Optional<TestBeanStore> getStore(Scope scope) {
265      Objects.requireNonNull(scope, "scope must not be null");
266      return o(scope == Scope.METHOD ? currentMethodStore() : currentClassStore());
267   }
268
269   //-----------------------------------------------------------------------------------------------------------------
270   // Lifecycle callbacks
271   //-----------------------------------------------------------------------------------------------------------------
272
273   @Override /* BeforeAllCallback */
274   public void beforeAll(ExtensionContext context) {
275      currentClassContext = context;
276      var testClass = context.getRequiredTestClass();
277      var scoped = buildClassScopeStoreWithMode(testClass);
278      context.getStore(NAMESPACE).put(KEY_CLASS_STORE, scoped.store());
279      // Mode OVERLAY push (if attached) — only fires when at least one class-scope @TestBean declared
280      // mode = Mode.OVERLAY.  All declarations in the scope must agree on the mode (mixing throws in
281      // buildClassScopeStoreWithMode).
282      var snapshot = pushOverlayIfOverlayMode(scoped);
283      if (snapshot != null)
284         context.getStore(NAMESPACE).put(KEY_CLASS_SNAPSHOT, snapshot);
285   }
286
287   @Override /* AfterAllCallback */
288   public void afterAll(ExtensionContext context) {
289      popOverlayIfPresent(context, KEY_CLASS_SNAPSHOT);
290      context.getStore(NAMESPACE).remove(KEY_CLASS_STORE);
291      currentClassContext = null;
292   }
293
294   @Override /* BeforeEachCallback */
295   public void beforeEach(ExtensionContext context) {
296      currentMethodContext = context;
297      var classStore = readClassStore(context);
298      var testInstance = context.getRequiredTestInstance();
299      var scoped = buildMethodScopeStoreWithMode(testInstance, classStore);
300      context.getStore(NAMESPACE).put(KEY_METHOD_STORE, scoped.store());
301      var snapshot = pushOverlayIfOverlayMode(scoped);
302      if (snapshot != null)
303         context.getStore(NAMESPACE).put(KEY_METHOD_SNAPSHOT, snapshot);
304   }
305
306   @Override /* AfterEachCallback */
307   public void afterEach(ExtensionContext context) {
308      popOverlayIfPresent(context, KEY_METHOD_SNAPSHOT);
309      context.getStore(NAMESPACE).remove(KEY_METHOD_STORE);
310      currentMethodContext = null;
311   }
312
313   //-----------------------------------------------------------------------------------------------------------------
314   // Mode OVERLAY push/pop helpers
315   //-----------------------------------------------------------------------------------------------------------------
316
317   private PushedOverlay pushOverlayIfOverlayMode(ScopedStore scoped) {
318      if (scoped.mode() != Mode.OVERLAY || scoped.empty())
319         return null;
320      var store = attached;
321      if (store == null)
322         throw new IllegalStateException(
323            "@TestBean(mode = Mode.OVERLAY) declared but JuneauBeanStoreExtension was not attached to a"
324            + " WritableBeanStore.  Call extension.attach(sut.getBeanStore()) from @BeforeAll / @BeforeEach"
325            + " before the test runs.");
326      var snapshot = store.pushOverlay(scoped.store());
327      return new PushedOverlay(store, snapshot);
328   }
329
330   private static void popOverlayIfPresent(ExtensionContext context, String key) {
331      var pushed = (PushedOverlay) context.getStore(NAMESPACE).remove(key);
332      if (pushed == null)
333         return;
334      // Pop against the original store reference captured at push time, so the pop works even if the test
335      // has since detach()-ed the extension or attach()-ed it to a different store.
336      pushed.store().popOverlay(pushed.snapshot());
337   }
338
339   /** Captures the {@link WritableBeanStore} an overlay was pushed against, plus the resulting {@link Snapshot}. */
340   private record PushedOverlay(WritableBeanStore store, Snapshot snapshot) { /* no body */ }
341
342   @Override /* ParameterResolver */
343   public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) {
344      return parameterContext.getParameter().getType() == TestBeanStore.class;
345   }
346
347   @Override /* ParameterResolver */
348   public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) {
349      // Prefer the method-scope store from the supplied extensionContext (the parameter being resolved belongs to
350      // the test method currently executing); fall back to class-scope when the parameter is resolved outside a
351      // method context (rare — e.g. constructor injection on a TestInstance.Lifecycle.PER_METHOD test class).
352      var methodStore = (TestBeanStore) extensionContext.getStore(NAMESPACE).get(KEY_METHOD_STORE);
353      if (methodStore != null)
354         return methodStore;
355      var classStore = readClassStore(extensionContext);
356      if (classStore != null)
357         return classStore;
358      throw new ParameterResolutionException(
359         "JuneauBeanStoreExtension cannot resolve TestBeanStore parameter: no overlay is active.");
360   }
361
362   //-----------------------------------------------------------------------------------------------------------------
363   // Discovery
364   //-----------------------------------------------------------------------------------------------------------------
365
366   /**
367    * Builds a class-scope overlay for the supplied test class.
368    *
369    * <p>
370    * Walks the class hierarchy parent-to-child registering every {@code @TestBean(scope = CLASS)} declared on a
371    * {@code static} field or {@code static} method.  Non-static {@code scope = CLASS} declarations throw an
372    * {@link ExtensionContextException} &mdash; instance state isn't available at {@code beforeAll} time.
373    *
374    * <p>
375    * Package-private to allow direct unit testing of the discovery logic without driving the full JUnit lifecycle.
376    */
377   static TestBeanStore buildClassScopeStore(Class<?> testClass) {
378      return buildClassScopeStoreWithMode(testClass).store();
379   }
380
381   /**
382    * Builds a method-scope overlay for the supplied test instance, optionally chained on top of a class-scope
383    * overlay.
384    *
385    * <p>
386    * Package-private to allow direct unit testing of the discovery logic without driving the full JUnit lifecycle.
387    */
388   static TestBeanStore buildMethodScopeStore(Object testInstance, TestBeanStore classScopeParent) {
389      return buildMethodScopeStoreWithMode(testInstance, classScopeParent).store();
390   }
391
392   /**
393    * {@link #buildClassScopeStore(Class)} plus the unified mode (INJECT or OVERLAY) of all class-scope
394    * declarations.
395    *
396    * <p>
397    * Mixing {@code Mode.INJECT} and {@code Mode.OVERLAY} declarations in the same scope throws an
398    * {@link IllegalStateException}.
399    */
400   static ScopedStore buildClassScopeStoreWithMode(Class<?> testClass) {
401      var store = new TestBeanStore();
402      var modeTracker = new ModeTracker(Scope.CLASS);
403      populateStaticOverrides(store, testClass, modeTracker);
404      return new ScopedStore(store, modeTracker.resolve(), modeTracker.empty());
405   }
406
407   /**
408    * {@link #buildMethodScopeStore(Object, TestBeanStore)} plus the unified mode (INJECT or OVERLAY) of all
409    * method-scope declarations.
410    *
411    * <p>
412    * Mixing {@code Mode.INJECT} and {@code Mode.OVERLAY} declarations in the same scope throws an
413    * {@link IllegalStateException}.
414    */
415   static ScopedStore buildMethodScopeStoreWithMode(Object testInstance, TestBeanStore classScopeParent) {
416      var store = new TestBeanStore(classScopeParent);
417      var modeTracker = new ModeTracker(Scope.METHOD);
418      populateInstanceOverrides(store, testInstance, modeTracker);
419      return new ScopedStore(store, modeTracker.resolve(), modeTracker.empty());
420   }
421
422   /**
423    * Discovers the {@code @TestBean} overrides declared on a test instance (and its class hierarchy) and returns
424    * them as a single {@link OverrideSet} &mdash; the public composition entry point for other JUnit 5 extensions
425    * (e.g. {@code @MicroserviceTest}) that need the {@code @TestBean} substrate without driving this extension's
426    * own lifecycle.
427    *
428    * <p>
429    * Both {@code @TestBean(scope = CLASS)} (static) and {@code @TestBean(scope = METHOD)} (instance) declarations
430    * are collected into one flattened overlay (method-scope chained over class-scope), so a composing extension
431    * that boots a single system-under-test per scope sees every declared override. The returned
432    * {@link OverrideSet#mode()} is the unified mode of all declarations (defaulting to {@link Mode#INJECT} when
433    * none are present); mixing {@code INJECT} and {@code OVERLAY} in one scope throws {@link IllegalStateException}.
434    *
435    * @param testInstance The test-class instance to scan. Must not be <jk>null</jk>.
436    * @return The discovered overrides + their unified mode. Never <jk>null</jk>; {@link OverrideSet#isEmpty()} is
437    *    <jk>true</jk> when no {@code @TestBean} members were found.
438    * @since 10.0.0
439    */
440   public static OverrideSet discoverOverrides(Object testInstance) {
441      reqnn("testInstance", testInstance);
442      var classScoped = buildClassScopeStoreWithMode(testInstance.getClass());
443      var methodScoped = buildMethodScopeStoreWithMode(testInstance, classScoped.store());
444      // Unify the two scopes' modes: an empty scope contributes no constraint; a populated one does.
445      var mode = methodScoped.empty() ? classScoped.mode() : methodScoped.mode();
446      if (! classScoped.empty() && ! methodScoped.empty() && classScoped.mode() != methodScoped.mode())
447         throw new IllegalStateException(
448            "Mixed @TestBean modes across scopes: CLASS declares Mode." + classScoped.mode()
449            + " but METHOD declares Mode." + methodScoped.mode()
450            + ".  All @TestBean declarations must use the same mode for a single-boot SUT.");
451      var empty = classScoped.empty() && methodScoped.empty();
452      return new OverrideSet(methodScoped.store(), mode, empty);
453   }
454
455   /**
456    * The flattened result of {@link #discoverOverrides(Object)}: the {@code @TestBean} overlay store, the unified
457    * {@link Mode} of all declarations, and whether any were found.
458    *
459    * @param store The overlay {@link BeanStore} carrying the discovered overrides (method-scope chained over
460    *    class-scope). Usable directly as a builder {@code overridingBeanStore(...)} argument for {@link Mode#INJECT},
461    *    or as a {@code pushOverlay(...)} argument for {@link Mode#OVERLAY}.
462    * @param mode The unified declaration mode ({@link Mode#INJECT} by default).
463    * @param empty Whether no {@code @TestBean} members were discovered.
464    * @since 10.0.0
465    */
466   public record OverrideSet(TestBeanStore store, Mode mode, boolean empty) {
467
468      /**
469       * Returns whether no {@code @TestBean} members were discovered.
470       *
471       * @return <jk>true</jk> if the override set is empty.
472       */
473      public boolean isEmpty() {
474         return empty;
475      }
476   }
477
478   /**
479    * Walks the test-class hierarchy and registers every {@code @TestBean(scope = CLASS)} declared on a
480    * {@code static} field or {@code static} method.
481    *
482    * <p>
483    * Non-static {@code scope = CLASS} declarations are rejected with an {@link ExtensionContextException} &mdash;
484    * instance state isn't available at {@code beforeAll} time.
485    */
486   private static void populateStaticOverrides(TestBeanStore store, Class<?> testClass, ModeTracker modeTracker) {
487      for (var c : classHierarchy(testClass)) {
488         for (var f : c.getDeclaredFields()) {
489            var ann = f.getAnnotation(TestBean.class);
490            if (ann == null || ann.scope() != Scope.CLASS)
491               continue;
492            if (!Modifier.isStatic(f.getModifiers()))
493               throw new ExtensionContextException(
494                  "@TestBean(scope = CLASS) field must be static: " + describe(f));
495            modeTracker.observe(ann.mode(), describe(f));
496            register(store, f, null, ann);
497         }
498         for (var m : c.getDeclaredMethods()) {
499            var ann = m.getAnnotation(TestBean.class);
500            if (ann == null || ann.scope() != Scope.CLASS)
501               continue;
502            if (!Modifier.isStatic(m.getModifiers()))
503               throw new ExtensionContextException(
504                  "@TestBean(scope = CLASS) method must be static: " + describe(m));
505            modeTracker.observe(ann.mode(), describe(m));
506            register(store, m, null, ann);
507         }
508      }
509   }
510
511   /**
512    * Walks the test-instance class hierarchy and registers every {@code @TestBean(scope = METHOD)} declared on a
513    * field or method.
514    *
515    * <p>
516    * Honors both instance and static members ("behave consistently with
517    * non-static" for {@code scope = METHOD} on static fields).
518    */
519   private static void populateInstanceOverrides(TestBeanStore store, Object testInstance, ModeTracker modeTracker) {
520      var testClass = testInstance.getClass();
521      for (var c : classHierarchy(testClass)) {
522         for (var f : c.getDeclaredFields()) {
523            var ann = f.getAnnotation(TestBean.class);
524            if (ann == null || ann.scope() != Scope.METHOD)
525               continue;
526            modeTracker.observe(ann.mode(), describe(f));
527            register(store, f, Modifier.isStatic(f.getModifiers()) ? null : testInstance, ann);
528         }
529         for (var m : c.getDeclaredMethods()) {
530            var ann = m.getAnnotation(TestBean.class);
531            if (ann == null || ann.scope() != Scope.METHOD)
532               continue;
533            modeTracker.observe(ann.mode(), describe(m));
534            register(store, m, Modifier.isStatic(m.getModifiers()) ? null : testInstance, ann);
535         }
536      }
537   }
538
539   /** Carries the discovered overlay plus the resolved {@link Mode} for a single scope. */
540   record ScopedStore(TestBeanStore store, Mode mode, boolean empty) { /* no body */ }
541
542   /**
543    * Tracks the {@link Mode} of {@code @TestBean} declarations encountered within a single scope and rejects
544    * mixed-mode declarations.
545    */
546   private static final class ModeTracker {
547      private final Scope scope;
548      private Mode observed;
549      private String firstMember;
550      private boolean empty = true;
551
552      ModeTracker(Scope scope) {
553         this.scope = scope;
554      }
555
556      void observe(Mode mode, String memberDesc) {
557         empty = false;
558         if (observed == null) {
559            observed = mode;
560            firstMember = memberDesc;
561            return;
562         }
563         if (observed != mode)
564            throw new IllegalStateException(
565               "Mixed @TestBean modes in " + scope + " scope: " + firstMember + " declares Mode." + observed
566               + " but " + memberDesc + " declares Mode." + mode
567               + ".  All @TestBean declarations in a single scope must use the same mode.");
568      }
569
570      Mode resolve() {
571         return observed == null ? Mode.INJECT : observed;
572      }
573
574      boolean empty() {
575         return empty;
576      }
577   }
578
579   private static void register(TestBeanStore store, Field field, Object instance, TestBean ann) {
580      Object value;
581      try {
582         field.setAccessible(true);
583         value = field.get(instance);
584      } catch (IllegalAccessException e) {
585         throw new ExtensionContextException("Could not read @TestBean field: " + describe(field), e);
586      }
587      var type = ann.type() == Object.class ? field.getType() : ann.type();
588      registerValue(store, type, value, ann.name(), describe(field));
589   }
590
591   private static void register(TestBeanStore store, Method method, Object instance, TestBean ann) {
592      if (method.getParameterCount() != 0)
593         throw new ExtensionContextException(
594            "@TestBean factory method must be parameterless: " + describe(method));
595      Object value;
596      try {
597         method.setAccessible(true);
598         value = method.invoke(instance);
599      } catch (ReflectiveOperationException e) {
600         throw new ExtensionContextException(
601            "Failed to invoke @TestBean factory method: " + describe(method),
602            or(e.getCause(), e));
603      }
604      var type = ann.type() == Object.class ? method.getReturnType() : ann.type();
605      registerValue(store, type, value, ann.name(), describe(method));
606   }
607
608   @SuppressWarnings({
609      "unchecked" // The type token came from either the annotated member's declared type or ann.type(); the value's runtime class is compatible by construction.
610   })
611   private static void registerValue(TestBeanStore store, Class<?> type, Object value, String name, String memberDesc) {
612      if (value != null && !type.isInstance(value))
613         throw new ExtensionContextException(
614            "@TestBean value is not assignable to declared type " + type.getName() + ": " + memberDesc);
615      store.override((Class<Object>) type, value, name.isEmpty() ? null : name);
616   }
617
618   //-----------------------------------------------------------------------------------------------------------------
619   // Internal helpers
620   //-----------------------------------------------------------------------------------------------------------------
621
622   private TestBeanStore currentMethodStore() {
623      return currentMethodContext == null ? null : (TestBeanStore) currentMethodContext.getStore(NAMESPACE).get(KEY_METHOD_STORE);
624   }
625
626   private TestBeanStore currentClassStore() {
627      return currentClassContext == null ? null : readClassStore(currentClassContext);
628   }
629
630   /**
631    * Reads the class-scope store from the nearest enclosing context that owns one.
632    *
633    * <p>
634    * JUnit's {@code ExtensionContext.Store} keys are scoped per context.  When a method-scope callback fires, the
635    * incoming context is the method-level context whose own store does <i>not</i> contain the class-scope entry;
636    * the class-scope entry lives on the parent (class-level) context.  This helper walks parents until it finds
637    * one whose store contains the class-scope entry, or returns {@code null} if none does.
638    */
639   private static TestBeanStore readClassStore(ExtensionContext context) {
640      for (var c = context; c != null; c = c.getParent().orElse(null)) {
641         var s = (TestBeanStore) c.getStore(NAMESPACE).get(KEY_CLASS_STORE);
642         if (s != null)
643            return s;
644      }
645      return null;
646   }
647
648   private static List<Class<?>> classHierarchy(Class<?> testClass) {
649      // Walk parent-to-child so a subclass's @TestBean of the same (type, name) wins over the parent's via the
650      // "last write wins" semantics of BasicBeanStore.addBean(...).
651      var hierarchy = new ArrayList<Class<?>>();
652      for (var c = testClass; c != null && c != Object.class; c = c.getSuperclass())
653         hierarchy.add(c);
654      Collections.reverse(hierarchy);
655      return hierarchy;
656   }
657
658   private static String describe(Field f) {
659      return f.getDeclaringClass().getName() + "#" + f.getName();
660   }
661
662   private static String describe(Method m) {
663      return m.getDeclaringClass().getName() + "#" + m.getName() + "()";
664   }
665}