@nocobase/flow-engine 2.1.0-beta.15 → 2.1.0-beta.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/lib/components/MobilePopup.js +6 -5
  2. package/lib/components/subModel/AddSubModelButton.js +1 -1
  3. package/lib/components/subModel/utils.js +2 -2
  4. package/lib/flowEngine.d.ts +132 -1
  5. package/lib/flowEngine.js +360 -14
  6. package/lib/flowSettings.d.ts +14 -6
  7. package/lib/flowSettings.js +34 -6
  8. package/lib/lazy-helper.d.ts +14 -0
  9. package/lib/lazy-helper.js +71 -0
  10. package/lib/models/flowModel.js +17 -7
  11. package/lib/types.d.ts +46 -0
  12. package/lib/utils/runjsTemplateCompat.js +1 -1
  13. package/package.json +4 -4
  14. package/src/__tests__/flow-engine.test.ts +166 -0
  15. package/src/__tests__/flowEngine.modelLoaders.test.ts +245 -0
  16. package/src/__tests__/flowSettings.test.ts +94 -15
  17. package/src/__tests__/renderHiddenInConfig.test.tsx +6 -6
  18. package/src/__tests__/viewScopedFlowEngine.test.ts +3 -3
  19. package/src/components/MobilePopup.tsx +4 -2
  20. package/src/components/__tests__/FlowModelRenderer.test.tsx +22 -0
  21. package/src/components/__tests__/flow-model-render-error-fallback.test.tsx +3 -3
  22. package/src/components/settings/wrappers/contextual/__tests__/FlowsFloatContextMenu.test.tsx +6 -6
  23. package/src/components/subModel/AddSubModelButton.tsx +1 -1
  24. package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +93 -33
  25. package/src/components/subModel/utils.ts +1 -1
  26. package/src/flowEngine.ts +412 -10
  27. package/src/flowSettings.ts +40 -6
  28. package/src/lazy-helper.tsx +57 -0
  29. package/src/models/flowModel.tsx +18 -6
  30. package/src/types.ts +59 -0
  31. package/src/utils/runjsTemplateCompat.ts +1 -1
@@ -56,13 +56,11 @@ var import_reactive = require("@formily/reactive");
56
56
  var import_lodash = __toESM(require("lodash"));
57
57
  var import_react = __toESM(require("react"));
58
58
  var import_secure = require("uid/secure");
59
- var import_StepRequiredSettingsDialog = require("../components/settings/wrappers/contextual/StepRequiredSettingsDialog");
60
- var import_StepSettingsDialog = require("../components/settings/wrappers/contextual/StepSettingsDialog");
61
59
  var import_emitter = require("../emitter");
62
60
  var import_InstanceFlowRegistry = require("../flow-registry/InstanceFlowRegistry");
63
61
  var import_flowContext = require("../flowContext");
64
62
  var import_utils = require("../utils");
65
- var import_lib = require("antd/lib");
63
+ var import_antd = require("antd");
66
64
  var import__ = require("..");
67
65
  var import_ModelActionRegistry = require("../action-registry/ModelActionRegistry");
68
66
  var import_utils2 = require("../components/subModel/utils");
@@ -75,6 +73,16 @@ const classEventRegistries = /* @__PURE__ */ new WeakMap();
75
73
  const modelMetas = /* @__PURE__ */ new WeakMap();
76
74
  const modelGlobalRegistries = /* @__PURE__ */ new WeakMap();
77
75
  const classMenuExtensions = /* @__PURE__ */ new WeakMap();
76
+ async function loadOpenStepSettingsDialog() {
77
+ const mod = await import("../components/settings/wrappers/contextual/StepSettingsDialog");
78
+ return mod.openStepSettingsDialog;
79
+ }
80
+ __name(loadOpenStepSettingsDialog, "loadOpenStepSettingsDialog");
81
+ async function loadOpenRequiredParamsStepFormDialog() {
82
+ const mod = await import("../components/settings/wrappers/contextual/StepRequiredSettingsDialog");
83
+ return mod.openRequiredParamsStepFormDialog;
84
+ }
85
+ __name(loadOpenRequiredParamsStepFormDialog, "loadOpenRequiredParamsStepFormDialog");
78
86
  var ModelRenderMode = /* @__PURE__ */ ((ModelRenderMode2) => {
79
87
  ModelRenderMode2["ReactElement"] = "reactElement";
80
88
  ModelRenderMode2["RenderFunction"] = "renderFunction";
@@ -1067,7 +1075,7 @@ const _FlowModel = class _FlowModel {
1067
1075
  * @param {string} stepKey 步骤的唯一标识符
1068
1076
  * @returns {void}
1069
1077
  */
1070
- openStepSettingsDialog(flowKey, stepKey) {
1078
+ async openStepSettingsDialog(flowKey, stepKey) {
1071
1079
  var _a;
1072
1080
  const flow = this.getFlow(flowKey);
1073
1081
  const step = (_a = flow == null ? void 0 : flow.steps) == null ? void 0 : _a[stepKey];
@@ -1078,7 +1086,8 @@ const _FlowModel = class _FlowModel {
1078
1086
  const ctx = new import_flowContext.FlowRuntimeContext(this, flowKey, "settings");
1079
1087
  (0, import_utils.setupRuntimeContextSteps)(ctx, flow.steps, this, flowKey);
1080
1088
  ctx.defineProperty("currentStep", { value: step });
1081
- return (0, import_StepSettingsDialog.openStepSettingsDialog)({
1089
+ const openStepSettingsDialog = await loadOpenStepSettingsDialog();
1090
+ return openStepSettingsDialog({
1082
1091
  model: this,
1083
1092
  flowKey,
1084
1093
  stepKey,
@@ -1093,7 +1102,8 @@ const _FlowModel = class _FlowModel {
1093
1102
  * @returns {Promise<any>} 返回表单提交的值
1094
1103
  */
1095
1104
  async configureRequiredSteps(dialogWidth, dialogTitle) {
1096
- return (0, import_StepRequiredSettingsDialog.openRequiredParamsStepFormDialog)({
1105
+ const openRequiredParamsStepFormDialog = await loadOpenRequiredParamsStepFormDialog();
1106
+ return openRequiredParamsStepFormDialog({
1097
1107
  model: this,
1098
1108
  dialogWidth,
1099
1109
  dialogTitle
@@ -1312,7 +1322,7 @@ const _ErrorFlowModel = class _ErrorFlowModel extends FlowModel {
1312
1322
  this.errorMessage = msg;
1313
1323
  }
1314
1324
  render() {
1315
- return /* @__PURE__ */ import_react.default.createElement(import_lib.Typography.Text, { type: "danger" }, this.errorMessage);
1325
+ return /* @__PURE__ */ import_react.default.createElement(import_antd.Typography.Text, { type: "danger" }, this.errorMessage);
1316
1326
  }
1317
1327
  };
1318
1328
  __name(_ErrorFlowModel, "ErrorFlowModel");
package/lib/types.d.ts CHANGED
@@ -303,6 +303,52 @@ export interface CreateModelOptions {
303
303
  delegateToParent?: boolean;
304
304
  [key: string]: any;
305
305
  }
306
+ /**
307
+ * FlowModel loader result.
308
+ * Supports returning the model constructor directly, a default export, or a module object containing the named export.
309
+ */
310
+ export type FlowModelLoaderResult = ModelConstructor | {
311
+ default?: ModelConstructor;
312
+ [key: string]: unknown;
313
+ } | Record<string, unknown>;
314
+ /**
315
+ * FlowModel loader function.
316
+ */
317
+ export type FlowModelLoader = () => Promise<FlowModelLoaderResult>;
318
+ /**
319
+ * FlowModel loader entry (normalized internal form).
320
+ */
321
+ export interface FlowModelLoaderEntry {
322
+ loader: FlowModelLoader;
323
+ extends?: string[];
324
+ }
325
+ /**
326
+ * FlowModel loader input (user-facing form for registerModelLoaders).
327
+ * The `extends` field accepts flexible formats that will be normalized to `string[]` at registration time.
328
+ */
329
+ export interface FlowModelLoaderInput {
330
+ loader: FlowModelLoader;
331
+ extends?: string | ModelConstructor | (string | ModelConstructor)[];
332
+ }
333
+ /**
334
+ * FlowModel loader entry map (normalized internal form).
335
+ */
336
+ export type FlowModelLoaderMap = Record<string, FlowModelLoaderEntry>;
337
+ /**
338
+ * FlowModel loader input map (user-facing form for registerModelLoaders).
339
+ */
340
+ export type FlowModelLoaderInputMap = Record<string, FlowModelLoaderInput>;
341
+ /**
342
+ * Batch ensure result.
343
+ */
344
+ export interface EnsureBatchResult {
345
+ requested: string[];
346
+ loaded: string[];
347
+ failed: Array<{
348
+ name: string;
349
+ error?: unknown;
350
+ }>;
351
+ }
306
352
  export interface IFlowModelRepository<T extends FlowModel = FlowModel> {
307
353
  findOne(query: Record<string, any>): Promise<Record<string, any> | null>;
308
354
  save(model: T, options?: {
@@ -527,8 +527,8 @@ function extractUsedCtxLibKeys(code) {
527
527
  }
528
528
  __name(extractUsedCtxLibKeys, "extractUsedCtxLibKeys");
529
529
  function injectEnsureLibsPreamble(code) {
530
- if (!CTX_LIBS_MARKER_RE.test(code)) return code;
531
530
  if (ENSURE_LIBS_MARKER_RE.test(code)) return code;
531
+ if (!CTX_LIBS_MARKER_RE.test(code)) return code;
532
532
  const keys = extractUsedCtxLibKeys(code);
533
533
  if (!keys.length) return code;
534
534
  return `/* __runjs_ensure_libs */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocobase/flow-engine",
3
- "version": "2.1.0-beta.15",
3
+ "version": "2.1.0-beta.16",
4
4
  "private": false,
5
5
  "description": "A standalone flow engine for NocoBase, managing workflows, models, and actions.",
6
6
  "main": "lib/index.js",
@@ -8,8 +8,8 @@
8
8
  "dependencies": {
9
9
  "@formily/antd-v5": "1.x",
10
10
  "@formily/reactive": "2.x",
11
- "@nocobase/sdk": "2.1.0-beta.15",
12
- "@nocobase/shared": "2.1.0-beta.15",
11
+ "@nocobase/sdk": "2.1.0-beta.16",
12
+ "@nocobase/shared": "2.1.0-beta.16",
13
13
  "ahooks": "^3.7.2",
14
14
  "axios": "^1.7.0",
15
15
  "dayjs": "^1.11.9",
@@ -37,5 +37,5 @@
37
37
  ],
38
38
  "author": "NocoBase Team",
39
39
  "license": "Apache-2.0",
40
- "gitHead": "dc1aceea6357e6ab149976c2a236fc4b6bee1370"
40
+ "gitHead": "b9a191705a440a336c85d82fd877fdf152bec70f"
41
41
  }
@@ -189,4 +189,170 @@ describe('FlowEngine', () => {
189
189
  expect(mounted?.uid).toBe('c3');
190
190
  });
191
191
  });
192
+
193
+ describe('getSubclassesOfAsync', () => {
194
+ it('should return async-loaded subclasses matching extends declaration', async () => {
195
+ class AsyncSubModelD extends BaseModel {}
196
+ class AsyncSubModelE extends BaseModel {}
197
+
198
+ engine.registerModelLoaders({
199
+ AsyncSubModelD: {
200
+ extends: 'BaseModel',
201
+ loader: async () => ({ AsyncSubModelD }),
202
+ },
203
+ AsyncSubModelE: {
204
+ extends: 'BaseModel',
205
+ loader: async () => ({ AsyncSubModelE }),
206
+ },
207
+ });
208
+
209
+ const result = await engine.getSubclassesOfAsync(BaseModel);
210
+
211
+ // Sync-registered subclasses
212
+ expect(result.has('SubModelA')).toBe(true);
213
+ expect(result.has('SubModelB')).toBe(true);
214
+ expect(result.has('SubModelC')).toBe(true);
215
+ // Async-loaded subclasses
216
+ expect(result.has('AsyncSubModelD')).toBe(true);
217
+ expect(result.has('AsyncSubModelE')).toBe(true);
218
+ // Base class excluded
219
+ expect(result.has('BaseModel')).toBe(false);
220
+ });
221
+
222
+ it('should merge sync-registered and async-loaded subclasses', async () => {
223
+ class AsyncSubModel extends BaseModel {}
224
+
225
+ engine.registerModelLoaders({
226
+ AsyncSubModel: {
227
+ extends: 'BaseModel',
228
+ loader: async () => ({ AsyncSubModel }),
229
+ },
230
+ });
231
+
232
+ const result = await engine.getSubclassesOfAsync('BaseModel');
233
+
234
+ // Sync: SubModelA, SubModelB, SubModelC
235
+ expect(result.has('SubModelA')).toBe(true);
236
+ expect(result.has('SubModelB')).toBe(true);
237
+ expect(result.has('SubModelC')).toBe(true);
238
+ // Async
239
+ expect(result.has('AsyncSubModel')).toBe(true);
240
+ expect(result.size).toBe(4);
241
+ });
242
+
243
+ it('should support extends as string array (multiple parents)', async () => {
244
+ class AnotherBase extends FlowModel {}
245
+ class MultiParentModel extends BaseModel {}
246
+
247
+ engine.registerModels({ AnotherBase });
248
+ engine.registerModelLoaders({
249
+ MultiParentModel: {
250
+ extends: ['BaseModel', 'AnotherBase'],
251
+ loader: async () => ({ MultiParentModel }),
252
+ },
253
+ });
254
+
255
+ const resultBase = await engine.getSubclassesOfAsync(BaseModel);
256
+ expect(resultBase.has('MultiParentModel')).toBe(true);
257
+
258
+ // Also found by AnotherBase (even though actual inheritance is from BaseModel, not AnotherBase)
259
+ // The extends declaration triggers loading, but isInheritedFrom validation will exclude it from AnotherBase results
260
+ const resultAnother = await engine.getSubclassesOfAsync(AnotherBase);
261
+ expect(resultAnother.has('MultiParentModel')).toBe(false);
262
+ });
263
+
264
+ it('should support extends as ModelConstructor', async () => {
265
+ class AsyncCtorSubModel extends BaseModel {}
266
+
267
+ engine.registerModelLoaders({
268
+ AsyncCtorSubModel: {
269
+ extends: BaseModel,
270
+ loader: async () => ({ AsyncCtorSubModel }),
271
+ },
272
+ });
273
+
274
+ const result = await engine.getSubclassesOfAsync(BaseModel);
275
+ expect(result.has('AsyncCtorSubModel')).toBe(true);
276
+ });
277
+
278
+ it('should validate actual inheritance and warn on mismatch', async () => {
279
+ class UnrelatedModel extends FlowModel {}
280
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
281
+
282
+ engine.registerModelLoaders({
283
+ UnrelatedModel: {
284
+ extends: 'BaseModel',
285
+ loader: async () => ({ UnrelatedModel }),
286
+ },
287
+ });
288
+
289
+ const result = await engine.getSubclassesOfAsync(BaseModel);
290
+ expect(result.has('UnrelatedModel')).toBe(false);
291
+ expect(warnSpy).toHaveBeenCalledWith(
292
+ expect.stringContaining("declares extends 'BaseModel' but does not actually inherit from it"),
293
+ );
294
+
295
+ warnSpy.mockRestore();
296
+ });
297
+
298
+ it('should resolve base class from loaders if not in _modelClasses', async () => {
299
+ const freshEngine = new FlowEngine();
300
+
301
+ class LazyBase extends FlowModel {}
302
+ class LazySub extends LazyBase {}
303
+
304
+ freshEngine.registerModelLoaders({
305
+ LazyBase: {
306
+ loader: async () => ({ LazyBase }),
307
+ },
308
+ LazySub: {
309
+ extends: 'LazyBase',
310
+ loader: async () => ({ LazySub }),
311
+ },
312
+ });
313
+
314
+ const result = await freshEngine.getSubclassesOfAsync('LazyBase');
315
+ expect(result.has('LazySub')).toBe(true);
316
+ expect(result.size).toBe(1);
317
+ });
318
+
319
+ it('should return empty Map when base class cannot be found', async () => {
320
+ const result = await engine.getSubclassesOfAsync('NonExistentModel');
321
+ expect(result.size).toBe(0);
322
+ });
323
+
324
+ it('should support filter parameter on both sync and async sources', async () => {
325
+ class FilteredAsyncModel extends BaseModel {}
326
+
327
+ engine.registerModelLoaders({
328
+ FilteredAsyncModel: {
329
+ extends: 'BaseModel',
330
+ loader: async () => ({ FilteredAsyncModel }),
331
+ },
332
+ });
333
+
334
+ const result = await engine.getSubclassesOfAsync(BaseModel, (_ModelClass, name) => name.startsWith('SubModelA'));
335
+
336
+ // Only SubModelA passes the filter (SubModelB, SubModelC, FilteredAsyncModel excluded)
337
+ expect(result.has('SubModelA')).toBe(true);
338
+ expect(result.has('SubModelB')).toBe(false);
339
+ expect(result.has('SubModelC')).toBe(false);
340
+ expect(result.has('FilteredAsyncModel')).toBe(false);
341
+ });
342
+
343
+ it('should not include loaders without extends declaration', async () => {
344
+ class NoExtendsModel extends BaseModel {}
345
+
346
+ engine.registerModelLoaders({
347
+ NoExtendsModel: {
348
+ loader: async () => ({ NoExtendsModel }),
349
+ },
350
+ });
351
+
352
+ const result = await engine.getSubclassesOfAsync(BaseModel);
353
+ // Only sync-registered subclasses; NoExtendsModel has no extends, so not discovered
354
+ expect(result.has('NoExtendsModel')).toBe(false);
355
+ expect(result.has('SubModelA')).toBe(true);
356
+ });
357
+ });
192
358
  });
@@ -0,0 +1,245 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
11
+ import { FlowEngine } from '../flowEngine';
12
+ import { ErrorFlowModel, FlowModel } from '../models';
13
+ import type { IFlowModelRepository } from '../types';
14
+
15
+ class MockFlowModelRepository implements IFlowModelRepository {
16
+ findOneResult: any = null;
17
+ save = vi.fn(async (model: FlowModel) => ({ success: true, uid: model.uid }));
18
+
19
+ async findOne() {
20
+ return this.findOneResult ? JSON.parse(JSON.stringify(this.findOneResult)) : null;
21
+ }
22
+
23
+ async destroy() {
24
+ return true;
25
+ }
26
+
27
+ async move() {}
28
+
29
+ async duplicate() {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ describe('FlowEngine model loaders', () => {
35
+ let engine: FlowEngine;
36
+ let repo: MockFlowModelRepository;
37
+
38
+ beforeEach(() => {
39
+ engine = new FlowEngine();
40
+ repo = new MockFlowModelRepository();
41
+ engine.setModelRepository(repo);
42
+ });
43
+
44
+ it('resolves explicit and meta-default model trees before synchronous creation', async () => {
45
+ class ParentModel extends FlowModel {}
46
+ class ChildModel extends FlowModel {}
47
+ class DefaultChildModel extends FlowModel {}
48
+
49
+ ParentModel.define({
50
+ createModelOptions: {
51
+ subModels: {
52
+ defaultChild: {
53
+ use: 'DefaultChildModel',
54
+ },
55
+ },
56
+ },
57
+ });
58
+
59
+ const parentLoader = vi.fn(async () => ({ ParentModel }));
60
+ const childLoader = vi.fn(async () => ({ ChildModel }));
61
+ const defaultChildLoader = vi.fn(async () => ({ DefaultChildModel }));
62
+
63
+ engine.registerModelLoaders({
64
+ ParentModel: { loader: parentLoader },
65
+ ChildModel: { loader: childLoader },
66
+ DefaultChildModel: { loader: defaultChildLoader },
67
+ });
68
+
69
+ const model = await engine.loadOrCreateModel({
70
+ uid: 'parent-model',
71
+ use: 'ParentModel',
72
+ subModels: {
73
+ child: {
74
+ use: 'ChildModel',
75
+ },
76
+ },
77
+ });
78
+
79
+ expect(model).toBeInstanceOf(ParentModel);
80
+ expect(model?.subModels.child).toBeInstanceOf(ChildModel);
81
+ expect(model?.subModels.defaultChild).toBeInstanceOf(DefaultChildModel);
82
+ expect(parentLoader).toHaveBeenCalledTimes(1);
83
+ expect(childLoader).toHaveBeenCalledTimes(1);
84
+ expect(defaultChildLoader).toHaveBeenCalledTimes(1);
85
+ });
86
+
87
+ it('resolves repository-loaded model trees before loadModel creates instances', async () => {
88
+ class RepoRootModel extends FlowModel {}
89
+ class RepoChildModel extends FlowModel {}
90
+
91
+ const rootLoader = vi.fn(async () => ({ RepoRootModel }));
92
+ const childLoader = vi.fn(async () => ({ RepoChildModel }));
93
+
94
+ engine.registerModelLoaders({
95
+ RepoRootModel: { loader: rootLoader },
96
+ RepoChildModel: { loader: childLoader },
97
+ });
98
+
99
+ repo.findOneResult = {
100
+ uid: 'repo-root',
101
+ use: 'RepoRootModel',
102
+ subModels: {
103
+ child: {
104
+ use: 'RepoChildModel',
105
+ },
106
+ },
107
+ };
108
+
109
+ const model = await engine.loadModel({ uid: 'repo-root' });
110
+
111
+ expect(model).toBeInstanceOf(RepoRootModel);
112
+ expect(model?.subModels.child).toBeInstanceOf(RepoChildModel);
113
+ expect(rootLoader).toHaveBeenCalledTimes(1);
114
+ expect(childLoader).toHaveBeenCalledTimes(1);
115
+ });
116
+
117
+ it('supports async model creation and async getters', async () => {
118
+ class AsyncRootModel extends FlowModel {}
119
+ class AsyncChildModel extends FlowModel {}
120
+
121
+ const rootLoader = vi.fn(async () => ({ AsyncRootModel }));
122
+ const childLoader = vi.fn(async () => ({ AsyncChildModel }));
123
+
124
+ engine.registerModelLoaders({
125
+ AsyncRootModel: { loader: rootLoader },
126
+ AsyncChildModel: { loader: childLoader },
127
+ });
128
+
129
+ const rootClass = await engine.getModelClassAsync('AsyncRootModel');
130
+ const classes = await engine.getModelClassesAsync();
131
+ const model = await engine.createModelAsync({
132
+ uid: 'async-root',
133
+ use: 'AsyncRootModel',
134
+ subModels: {
135
+ child: {
136
+ use: 'AsyncChildModel',
137
+ },
138
+ },
139
+ });
140
+
141
+ expect(rootClass).toBe(AsyncRootModel);
142
+ expect(classes.get('AsyncRootModel')).toBe(AsyncRootModel);
143
+ expect(classes.get('AsyncChildModel')).toBe(AsyncChildModel);
144
+ expect(model).toBeInstanceOf(AsyncRootModel);
145
+ expect(model.subModels.child).toBeInstanceOf(AsyncChildModel);
146
+ expect(rootLoader).toHaveBeenCalledTimes(1);
147
+ expect(childLoader).toHaveBeenCalledTimes(1);
148
+ });
149
+
150
+ it('createModelAsync degrades unresolved loader failures to ErrorFlowModel', async () => {
151
+ const invalidLoader = vi.fn(async () => ({ notAModel: {} }));
152
+
153
+ engine.registerModelLoaders({
154
+ BrokenRootModel: { loader: invalidLoader as any },
155
+ });
156
+
157
+ const model = await engine.createModelAsync({
158
+ uid: 'broken-root',
159
+ use: 'BrokenRootModel',
160
+ });
161
+
162
+ expect(model).toBeInstanceOf(ErrorFlowModel);
163
+ expect(invalidLoader).toHaveBeenCalledTimes(1);
164
+ });
165
+
166
+ it('keeps loader resolution idempotent across resolveModelTree and flow settings preload', async () => {
167
+ class RuntimeResolvedModel extends FlowModel {}
168
+ class DesignResolvedModel extends FlowModel {}
169
+
170
+ const runtimeLoader = vi.fn(async () => ({ RuntimeResolvedModel }));
171
+ const designLoader = vi.fn(async () => ({ DesignResolvedModel }));
172
+
173
+ engine.registerModelLoaders({
174
+ RuntimeResolvedModel: { loader: runtimeLoader },
175
+ DesignResolvedModel: { loader: designLoader },
176
+ });
177
+
178
+ await engine.resolveModelTree({
179
+ use: 'RuntimeResolvedModel',
180
+ });
181
+
182
+ const firstPreload = await engine.preloadModelLoaders();
183
+ const secondPreload = await engine.preloadModelLoaders();
184
+
185
+ expect(runtimeLoader).toHaveBeenCalledTimes(1);
186
+ expect(designLoader).toHaveBeenCalledTimes(1);
187
+ expect(firstPreload.loaded).toContain('DesignResolvedModel');
188
+ expect(firstPreload.loaded).not.toContain('RuntimeResolvedModel');
189
+ expect(secondPreload.loaded).toHaveLength(0);
190
+ expect(secondPreload.failed).toHaveLength(0);
191
+ });
192
+
193
+ it('picks up newly registered loaders after preload has already completed', async () => {
194
+ class FirstModel extends FlowModel {}
195
+ class SecondModel extends FlowModel {}
196
+
197
+ const firstLoader = vi.fn(async () => ({ FirstModel }));
198
+ const secondLoader = vi.fn(async () => ({ SecondModel }));
199
+
200
+ engine.registerModelLoaders({
201
+ FirstModel: { loader: firstLoader },
202
+ });
203
+
204
+ await engine.preloadModelLoaders();
205
+ expect(firstLoader).toHaveBeenCalledTimes(1);
206
+ expect(engine.getModelClass('FirstModel')).toBe(FirstModel);
207
+
208
+ engine.registerModelLoaders({
209
+ SecondModel: { loader: secondLoader },
210
+ });
211
+
212
+ const result = await engine.preloadModelLoaders();
213
+
214
+ expect(secondLoader).toHaveBeenCalledTimes(1);
215
+ expect(result.loaded).toContain('SecondModel');
216
+ expect(engine.getModelClass('SecondModel')).toBe(SecondModel);
217
+ });
218
+
219
+ it('degrades unresolved loader failures to ErrorFlowModel instead of crashing runtime creation', async () => {
220
+ class ParentModel extends FlowModel {}
221
+
222
+ const parentLoader = vi.fn(async () => ({ ParentModel }));
223
+ const invalidChildLoader = vi.fn(async () => ({ notAModel: {} }));
224
+
225
+ engine.registerModelLoaders({
226
+ ParentModel: { loader: parentLoader },
227
+ BrokenChildModel: { loader: invalidChildLoader as any },
228
+ });
229
+
230
+ const model = await engine.loadOrCreateModel({
231
+ uid: 'parent-with-broken-child',
232
+ use: 'ParentModel',
233
+ subModels: {
234
+ child: {
235
+ use: 'BrokenChildModel',
236
+ },
237
+ },
238
+ });
239
+
240
+ expect(model).toBeInstanceOf(ParentModel);
241
+ expect(model?.subModels.child).toBeInstanceOf(ErrorFlowModel);
242
+ expect(parentLoader).toHaveBeenCalledTimes(1);
243
+ expect(invalidChildLoader).toHaveBeenCalledTimes(1);
244
+ });
245
+ });