1   /*
2    * SPDX-FileCopyrightText: none
3    * SPDX-License-Identifier: CC0-1.0
4    */
5   
6   package dev.metaschema.core.testsupport.builder;
7   
8   import static org.mockito.ArgumentMatchers.eq;
9   import static org.mockito.Mockito.doReturn;
10  
11  import java.net.URI;
12  import java.util.ArrayList;
13  import java.util.LinkedHashMap;
14  import java.util.List;
15  import java.util.Map;
16  import java.util.stream.Collectors;
17  
18  import dev.metaschema.core.model.IAssemblyDefinition;
19  import dev.metaschema.core.model.IFieldDefinition;
20  import dev.metaschema.core.model.IFlagDefinition;
21  import dev.metaschema.core.model.IModule;
22  import dev.metaschema.core.model.ISource;
23  import dev.metaschema.core.qname.IEnhancedQName;
24  import dev.metaschema.core.testsupport.mocking.AbstractMockitoFactory;
25  import dev.metaschema.core.util.CollectionUtil;
26  import dev.metaschema.core.util.ObjectUtils;
27  import edu.umd.cs.findbugs.annotations.NonNull;
28  import edu.umd.cs.findbugs.annotations.Nullable;
29  
30  /**
31   * A builder for creating mock {@link IModule} instances for testing purposes.
32   */
33  final class ModuleBuilder
34      extends AbstractMockitoFactory
35      implements IModuleBuilder {
36  
37    private String namespace;
38    private String shortName;
39    private String version;
40    private ISource source;
41    private final List<IFlagBuilder> flagBuilders = new ArrayList<>();
42    private final List<IFieldBuilder> fieldBuilders = new ArrayList<>();
43    private final List<IAssemblyBuilder> assemblyBuilders = new ArrayList<>();
44  
45    ModuleBuilder() {
46      // package-private constructor
47    }
48  
49    @Override
50    @NonNull
51    public IModuleBuilder reset() {
52      this.namespace = null;
53      this.shortName = null;
54      this.version = null;
55      this.source = null;
56      this.flagBuilders.clear();
57      this.fieldBuilders.clear();
58      this.assemblyBuilders.clear();
59      return this;
60    }
61  
62    @Override
63    @NonNull
64    public IModuleBuilder namespace(@NonNull String namespace) {
65      this.namespace = namespace;
66      return this;
67    }
68  
69    @Override
70    @NonNull
71    public IModuleBuilder shortName(@NonNull String shortName) {
72      this.shortName = shortName;
73      return this;
74    }
75  
76    @Override
77    @NonNull
78    public IModuleBuilder version(@NonNull String version) {
79      this.version = version;
80      return this;
81    }
82  
83    @Override
84    @NonNull
85    public IModuleBuilder source(@NonNull ISource source) {
86      this.source = source;
87      return this;
88    }
89  
90    @Override
91    @NonNull
92    public IModuleBuilder flag(@Nullable IFlagBuilder flag) {
93      if (flag != null) {
94        this.flagBuilders.add(flag);
95      }
96      return this;
97    }
98  
99    @Override
100   @NonNull
101   public IModuleBuilder field(@Nullable IFieldBuilder field) {
102     if (field != null) {
103       this.fieldBuilders.add(field);
104     }
105     return this;
106   }
107 
108   @Override
109   @NonNull
110   public IModuleBuilder assembly(@Nullable IAssemblyBuilder assembly) {
111     if (assembly != null) {
112       this.assemblyBuilders.add(assembly);
113     }
114     return this;
115   }
116 
117   /**
118    * Validate that required fields are set.
119    */
120   private void validate() {
121     ObjectUtils.requireNonNull(namespace, "namespace");
122     ObjectUtils.requireNonNull(shortName, "shortName");
123     ObjectUtils.requireNonNull(version, "version");
124     ObjectUtils.requireNonNull(source, "source");
125   }
126 
127   @Override
128   @NonNull
129   public IModule toModule() {
130     validate();
131 
132     IModule module = mock(IModule.class);
133 
134     // Basic metadata
135     URI namespaceUri = URI.create(ObjectUtils.notNull(namespace));
136     doReturn(namespaceUri).when(module).getXmlNamespace();
137     doReturn(namespaceUri).when(module).getJsonBaseUri();
138     doReturn(shortName).when(module).getShortName();
139     doReturn(version).when(module).getVersion();
140     doReturn(source).when(module).getSource();
141 
142     // Location information
143     URI sourceUri = source.getSource();
144     doReturn(sourceUri).when(module).getLocation();
145     String locationHint = sourceUri != null ? sourceUri.toString() : shortName;
146     doReturn(locationHint).when(module).getLocationHint();
147 
148     // Module QName
149     IEnhancedQName qname = IEnhancedQName.of(namespace, shortName);
150     doReturn(qname).when(module).getQName();
151 
152     // Imported modules
153     doReturn(CollectionUtil.emptyList()).when(module).getImportedModules();
154     doReturn(null).when(module).getImportedModuleByShortName(org.mockito.ArgumentMatchers.anyString());
155 
156     // Name and remarks
157     doReturn(null).when(module).getName();
158     doReturn(null).when(module).getRemarks();
159 
160     // Build definitions from accumulated builders
161     buildDefinitions(module);
162 
163     return module;
164   }
165 
166   /**
167    * Build all accumulated definitions and wire them to the module.
168    *
169    * @param module
170    *          the module to wire definitions to
171    */
172   private void buildDefinitions(@NonNull IModule module) {
173     String moduleNamespace = ObjectUtils.notNull(namespace);
174     ISource moduleSource = ObjectUtils.notNull(source);
175 
176     // Build flag definitions
177     List<IFlagDefinition> flagDefs = new ArrayList<>();
178     for (IFlagBuilder builder : flagBuilders) {
179       IFlagDefinition def = builder
180           .namespace(moduleNamespace)
181           .source(moduleSource)
182           .toDefinition(module);
183       flagDefs.add(def);
184     }
185     doReturn(CollectionUtil.unmodifiableList(flagDefs)).when(module).getFlagDefinitions();
186     // Set up lookup by QName - extract qname first to avoid nested stubbing issues
187     for (IFlagDefinition def : flagDefs) {
188       IEnhancedQName qname = def.getDefinitionQName();
189       doReturn(def).when(module).getFlagDefinitionByName(eq(qname));
190     }
191 
192     // Build field definitions - keep a map for reference resolution
193     Map<String, IFieldDefinition> fieldDefsByName = new LinkedHashMap<>();
194     for (IFieldBuilder builder : fieldBuilders) {
195       IFieldDefinition def = builder
196           .namespace(moduleNamespace)
197           .source(moduleSource)
198           .toDefinition(module);
199       fieldDefsByName.put(def.getName(), def);
200     }
201     List<IFieldDefinition> fieldDefs = new ArrayList<>(fieldDefsByName.values());
202     doReturn(CollectionUtil.unmodifiableList(fieldDefs)).when(module).getFieldDefinitions();
203     // Set up lookup by index position - extract index first to avoid nested
204     // stubbing issues
205     for (IFieldDefinition def : fieldDefs) {
206       Integer index = def.getDefinitionQName().getIndexPosition();
207       doReturn(def).when(module).getFieldDefinitionByName(eq(index));
208     }
209 
210     // Check if any assembly has references that need lazy resolution
211     boolean hasReferences = assemblyBuilders.stream()
212         .filter(AssemblyBuilder.class::isInstance)
213         .map(AssemblyBuilder.class::cast)
214         .anyMatch(AssemblyBuilder::hasModelReferences);
215 
216     // Build assembly definitions - use two-phase if there are references
217     Map<String, IAssemblyDefinition> assemblyDefsByName = new LinkedHashMap<>();
218     Map<AssemblyBuilder, IAssemblyDefinition> builderToDefMap = new LinkedHashMap<>();
219 
220     if (hasReferences) {
221       // Phase 1: Build all assembly shells (without model instances)
222       for (IAssemblyBuilder builder : assemblyBuilders) {
223         if (!(builder instanceof AssemblyBuilder)) {
224           throw new IllegalStateException(
225               "Two-phase construction requires AssemblyBuilder instances, got: " + builder.getClass().getName());
226         }
227         AssemblyBuilder ab = (AssemblyBuilder) builder;
228         ab.namespace(moduleNamespace).source(moduleSource);
229         IAssemblyDefinition def = ab.toDefinitionShell(module);
230         assemblyDefsByName.put(def.getName(), def);
231         builderToDefMap.put(ab, def);
232       }
233 
234       // Phase 2: Resolve model instances for all assemblies
235       for (Map.Entry<AssemblyBuilder, IAssemblyDefinition> entry : builderToDefMap.entrySet()) {
236         AssemblyBuilder ab = entry.getKey();
237         IAssemblyDefinition def = entry.getValue();
238         ab.resolveModelInstances(def, assemblyDefsByName, fieldDefsByName);
239       }
240     } else {
241       // No references - build normally
242       for (IAssemblyBuilder builder : assemblyBuilders) {
243         IAssemblyDefinition def = builder
244             .namespace(moduleNamespace)
245             .source(moduleSource)
246             .toDefinition(module);
247         assemblyDefsByName.put(def.getName(), def);
248       }
249     }
250 
251     List<IAssemblyDefinition> assemblyDefs = new ArrayList<>(assemblyDefsByName.values());
252     doReturn(CollectionUtil.unmodifiableList(assemblyDefs)).when(module).getAssemblyDefinitions();
253     // Set up lookup by index position - extract index first to avoid nested
254     // stubbing issues
255     for (IAssemblyDefinition def : assemblyDefs) {
256       Integer index = def.getDefinitionQName().getIndexPosition();
257       doReturn(def).when(module).getAssemblyDefinitionByName(eq(index));
258     }
259 
260     // Set up export methods - for modules without imports, exported equals local
261     doReturn(CollectionUtil.unmodifiableList(flagDefs)).when(module).getExportedFlagDefinitions();
262     doReturn(CollectionUtil.unmodifiableList(fieldDefs)).when(module).getExportedFieldDefinitions();
263     doReturn(CollectionUtil.unmodifiableList(assemblyDefs)).when(module).getExportedAssemblyDefinitions();
264 
265     // Root assembly definitions - assemblies that have rootQName set
266     List<IAssemblyDefinition> rootDefs = assemblyDefs.stream()
267         .filter(def -> def.getRootQName() != null)
268         .collect(Collectors.toList());
269     doReturn(CollectionUtil.unmodifiableList(rootDefs)).when(module).getRootAssemblyDefinitions();
270     doReturn(CollectionUtil.unmodifiableList(rootDefs)).when(module).getExportedRootAssemblyDefinitions();
271 
272     // Set up root assembly lookup by name
273     for (IAssemblyDefinition rootDef : rootDefs) {
274       IEnhancedQName rootQName = rootDef.getRootQName();
275       if (rootQName != null) {
276         Integer rootIndex = rootQName.getIndexPosition();
277         doReturn(rootDef).when(module).getExportedRootAssemblyDefinitionByName(eq(rootIndex));
278       }
279     }
280 
281     // Combined assembly and field definitions
282     List<Object> assemblyAndFieldDefs = new ArrayList<>();
283     assemblyAndFieldDefs.addAll(assemblyDefs);
284     assemblyAndFieldDefs.addAll(fieldDefs);
285     doReturn(CollectionUtil.unmodifiableList(assemblyAndFieldDefs)).when(module).getAssemblyAndFieldDefinitions();
286 
287     // Scoped methods - for modules without imports, scoped equals local
288     for (IFlagDefinition def : flagDefs) {
289       IEnhancedQName qname = def.getDefinitionQName();
290       doReturn(def).when(module).getScopedFlagDefinitionByName(eq(qname));
291     }
292     for (IFieldDefinition def : fieldDefs) {
293       Integer index = def.getDefinitionQName().getIndexPosition();
294       doReturn(def).when(module).getScopedFieldDefinitionByName(eq(index));
295     }
296     for (IAssemblyDefinition def : assemblyDefs) {
297       Integer index = def.getDefinitionQName().getIndexPosition();
298       doReturn(def).when(module).getScopedAssemblyDefinitionByName(eq(index));
299     }
300   }
301 }