@equinor/fusion-framework-module-context 8.0.3 → 9.0.0-next.0
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.
- package/CHANGELOG.md +76 -0
- package/README.md +78 -2
- package/dist/esm/ContextModuleConfig.js +2 -0
- package/dist/esm/ContextModuleConfig.js.map +1 -0
- package/dist/esm/ContextModuleConfigurator.interface.js +2 -0
- package/dist/esm/ContextModuleConfigurator.interface.js.map +1 -0
- package/dist/esm/ContextModuleConfigurator.js +211 -0
- package/dist/esm/ContextModuleConfigurator.js.map +1 -0
- package/dist/esm/ContextProvider.js +1 -1
- package/dist/esm/ContextProvider.js.map +1 -1
- package/dist/esm/__tests__/ContextModuleConfigurator.test.js +282 -0
- package/dist/esm/__tests__/ContextModuleConfigurator.test.js.map +1 -0
- package/dist/esm/__tests__/mock/context-mock.test.js +101 -0
- package/dist/esm/__tests__/mock/context-mock.test.js.map +1 -0
- package/dist/esm/__tests__/mock/create-context-item-factory.test.js +45 -0
- package/dist/esm/__tests__/mock/create-context-item-factory.test.js.map +1 -0
- package/dist/esm/__tests__/mock/create-context-items.test.js +46 -0
- package/dist/esm/__tests__/mock/create-context-items.test.js.map +1 -0
- package/dist/esm/index.js +1 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/mock/ContextMockConfigurator.js +206 -0
- package/dist/esm/mock/ContextMockConfigurator.js.map +1 -0
- package/dist/esm/mock/fixtures/create-context-item-factory.js +50 -0
- package/dist/esm/mock/fixtures/create-context-item-factory.js.map +1 -0
- package/dist/esm/mock/fixtures/create-context-items.js +53 -0
- package/dist/esm/mock/fixtures/create-context-items.js.map +1 -0
- package/dist/esm/mock/fixtures/index.js +22 -0
- package/dist/esm/mock/fixtures/index.js.map +1 -0
- package/dist/esm/mock/fixtures/string-to-seed.js +15 -0
- package/dist/esm/mock/fixtures/string-to-seed.js.map +1 -0
- package/dist/esm/mock/index.js +31 -0
- package/dist/esm/mock/index.js.map +1 -0
- package/dist/esm/mock/module.js +41 -0
- package/dist/esm/mock/module.js.map +1 -0
- package/dist/esm/module.js +35 -6
- package/dist/esm/module.js.map +1 -1
- package/dist/esm/{get-context-selector.js → selectors/get-context-selector.js} +1 -4
- package/dist/esm/selectors/get-context-selector.js.map +1 -0
- package/dist/esm/selectors/index.js +13 -0
- package/dist/esm/selectors/index.js.map +1 -0
- package/dist/esm/{query-context-selector.js → selectors/query-context-selector.js} +1 -1
- package/dist/esm/selectors/query-context-selector.js.map +1 -0
- package/dist/esm/{related-context-selector.js → selectors/related-context-selector.js} +1 -1
- package/dist/esm/selectors/related-context-selector.js.map +1 -0
- package/dist/esm/utils/enable-context.js +1 -1
- package/dist/esm/utils/enable-context.js.map +1 -1
- package/dist/esm/utils/index.js +2 -0
- package/dist/esm/utils/index.js.map +1 -1
- package/dist/esm/utils/parse-context-item.js.map +1 -0
- package/dist/esm/utils/resolve-initial-context.js +3 -1
- package/dist/esm/utils/resolve-initial-context.js.map +1 -1
- package/dist/esm/version.js +1 -1
- package/dist/esm/version.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/types/{configurator.d.ts → ContextModuleConfig.d.ts} +4 -64
- package/dist/types/ContextModuleConfigurator.d.ts +97 -0
- package/dist/types/{ContextConfigBuilder.d.ts → ContextModuleConfigurator.interface.d.ts} +27 -47
- package/dist/types/ContextProvider.d.ts +1 -1
- package/dist/types/__tests__/ContextModuleConfigurator.test.d.ts +1 -0
- package/dist/types/__tests__/mock/context-mock.test.d.ts +1 -0
- package/dist/types/__tests__/mock/create-context-item-factory.test.d.ts +1 -0
- package/dist/types/__tests__/mock/create-context-items.test.d.ts +1 -0
- package/dist/types/index.d.ts +3 -1
- package/dist/types/mock/ContextMockConfigurator.d.ts +155 -0
- package/dist/types/mock/fixtures/create-context-item-factory.d.ts +40 -0
- package/dist/types/mock/fixtures/create-context-items.d.ts +59 -0
- package/dist/types/mock/fixtures/index.d.ts +21 -0
- package/dist/types/mock/fixtures/string-to-seed.d.ts +12 -0
- package/dist/types/mock/index.d.ts +30 -0
- package/dist/types/mock/module.d.ts +38 -0
- package/dist/types/module.d.ts +5 -3
- package/dist/types/{get-context-selector.d.ts → selectors/get-context-selector.d.ts} +1 -3
- package/dist/types/selectors/index.d.ts +12 -0
- package/dist/types/{query-context-selector.d.ts → selectors/query-context-selector.d.ts} +1 -1
- package/dist/types/{related-context-selector.d.ts → selectors/related-context-selector.d.ts} +1 -1
- package/dist/types/utils/enable-context.d.ts +4 -5
- package/dist/types/utils/index.d.ts +2 -0
- package/dist/types/{parse-context-item.d.ts → utils/parse-context-item.d.ts} +1 -1
- package/dist/types/utils/resolve-initial-context.d.ts +1 -1
- package/dist/types/version.d.ts +1 -1
- package/docs/data-model.md +134 -0
- package/docs/lifecycle.md +163 -0
- package/docs/recipes.md +89 -0
- package/package.json +36 -8
- package/src/ContextModuleConfig.ts +147 -0
- package/src/ContextModuleConfigurator.interface.ts +145 -0
- package/src/ContextModuleConfigurator.ts +295 -0
- package/src/ContextProvider.ts +1 -2
- package/src/__tests__/ContextModuleConfigurator.test.ts +412 -0
- package/src/__tests__/mock/context-mock.test.ts +137 -0
- package/src/__tests__/mock/create-context-item-factory.test.ts +60 -0
- package/src/__tests__/mock/create-context-items.test.ts +58 -0
- package/src/index.ts +3 -5
- package/src/mock/ContextMockConfigurator.ts +244 -0
- package/src/mock/fixtures/create-context-item-factory.ts +62 -0
- package/src/mock/fixtures/create-context-items.ts +80 -0
- package/src/mock/fixtures/index.ts +28 -0
- package/src/mock/fixtures/string-to-seed.ts +18 -0
- package/src/mock/index.ts +33 -0
- package/src/mock/module.ts +54 -0
- package/src/module.ts +45 -17
- package/src/{get-context-selector.ts → selectors/get-context-selector.ts} +2 -6
- package/src/selectors/index.ts +12 -0
- package/src/{query-context-selector.ts → selectors/query-context-selector.ts} +2 -2
- package/src/{related-context-selector.ts → selectors/related-context-selector.ts} +2 -2
- package/src/utils/enable-context.ts +6 -10
- package/src/utils/index.ts +2 -0
- package/src/{parse-context-item.ts → utils/parse-context-item.ts} +1 -1
- package/src/utils/resolve-initial-context.ts +5 -2
- package/src/version.ts +1 -1
- package/tsconfig.json +6 -0
- package/vitest.config.ts +11 -0
- package/dist/esm/ContextConfigBuilder.js +0 -175
- package/dist/esm/ContextConfigBuilder.js.map +0 -1
- package/dist/esm/configurator.js +0 -104
- package/dist/esm/configurator.js.map +0 -1
- package/dist/esm/get-context-selector.js.map +0 -1
- package/dist/esm/parse-context-item.js.map +0 -1
- package/dist/esm/query-context-selector.js.map +0 -1
- package/dist/esm/related-context-selector.js.map +0 -1
- package/src/ContextConfigBuilder.ts +0 -258
- package/src/configurator.ts +0 -300
- /package/dist/esm/{parse-context-item.js → utils/parse-context-item.js} +0 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
import { from, firstValueFrom, type ObservableInput } from 'rxjs';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
ModulesConfigurator,
|
|
6
|
+
type ConfigBuilderCallbackArgs,
|
|
7
|
+
} from '@equinor/fusion-framework-module';
|
|
8
|
+
import { configureHttp } from '@equinor/fusion-framework-module-http';
|
|
9
|
+
import { createRouterMiddleware } from '@equinor/fusion-framework-module-http/mock';
|
|
10
|
+
import type { IApiProvider } from '@equinor/fusion-framework-module-services';
|
|
11
|
+
import { enableServices } from '@equinor/fusion-framework-module-services';
|
|
12
|
+
import {
|
|
13
|
+
enableTelemetryMock,
|
|
14
|
+
MockTelemetryAdapter,
|
|
15
|
+
} from '@equinor/fusion-framework-module-telemetry/mock';
|
|
16
|
+
import { TelemetryLevel, TelemetryScope } from '@equinor/fusion-framework-module-telemetry';
|
|
17
|
+
import type { QueryCtorOptions } from '@equinor/fusion-query';
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
ContextModuleConfigurator,
|
|
21
|
+
type IContextModuleConfigurator,
|
|
22
|
+
} from '../ContextModuleConfigurator';
|
|
23
|
+
import type { ContextProvider } from '../ContextProvider';
|
|
24
|
+
import { module as contextModule } from '../module';
|
|
25
|
+
import type { ContextItem } from '../types';
|
|
26
|
+
|
|
27
|
+
type RawContextEntity = {
|
|
28
|
+
id: string;
|
|
29
|
+
externalId: string | null;
|
|
30
|
+
source: string | null;
|
|
31
|
+
type: { id: string; isChildType?: boolean; parentTypeIds?: string[] };
|
|
32
|
+
value: Record<string, unknown> | null;
|
|
33
|
+
title: string | null;
|
|
34
|
+
isActive: boolean;
|
|
35
|
+
isDeleted: boolean;
|
|
36
|
+
created: string;
|
|
37
|
+
updated: string | null;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const createRawContextEntity = (overrides: Partial<RawContextEntity> = {}): RawContextEntity => ({
|
|
41
|
+
id: 'ctx-1',
|
|
42
|
+
externalId: null,
|
|
43
|
+
source: null,
|
|
44
|
+
type: { id: 'ProjectMaster', isChildType: false, parentTypeIds: [] },
|
|
45
|
+
value: { key: 'value' },
|
|
46
|
+
title: 'My project',
|
|
47
|
+
isActive: true,
|
|
48
|
+
isDeleted: false,
|
|
49
|
+
created: '2024-01-01T00:00:00.000Z',
|
|
50
|
+
updated: null,
|
|
51
|
+
...overrides,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
/** Fakes the resolved `ContextApiClient`, invoking the selector against a canned raw payload. */
|
|
55
|
+
const createApiProvider = (): IApiProvider =>
|
|
56
|
+
({
|
|
57
|
+
createContextClient: vi.fn().mockResolvedValue({
|
|
58
|
+
get: (_version: string, _args: unknown, opts: { selector: (r: Response) => unknown }) =>
|
|
59
|
+
opts.selector(new Response(JSON.stringify(createRawContextEntity()))),
|
|
60
|
+
query: (_version: string, _args: unknown, opts: { selector: (r: Response) => unknown }) =>
|
|
61
|
+
opts.selector(new Response(JSON.stringify([createRawContextEntity({ id: 'ctx-2' })]))),
|
|
62
|
+
related: (_version: string, _args: unknown, opts: { selector: (r: Response) => unknown }) =>
|
|
63
|
+
opts.selector(new Response(JSON.stringify([createRawContextEntity({ id: 'ctx-3' })]))),
|
|
64
|
+
}),
|
|
65
|
+
}) as unknown as IApiProvider;
|
|
66
|
+
|
|
67
|
+
const createInitArgs = (
|
|
68
|
+
overrides: Partial<ConfigBuilderCallbackArgs> = {},
|
|
69
|
+
): ConfigBuilderCallbackArgs => ({
|
|
70
|
+
config: {},
|
|
71
|
+
hasModule: vi.fn().mockReturnValue(false),
|
|
72
|
+
requireInstance: vi.fn(),
|
|
73
|
+
...overrides,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const createMockClient = () => ({
|
|
77
|
+
get: () => Promise.resolve({ id: 'custom', type: { id: 'Mock' }, value: {} } as ContextItem),
|
|
78
|
+
query: () => Promise.resolve([] as ContextItem[]),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const invoke = <TResult, TArgs>(
|
|
82
|
+
options: QueryCtorOptions<TResult, TArgs>,
|
|
83
|
+
args: TArgs,
|
|
84
|
+
): Promise<TResult> => {
|
|
85
|
+
const client = options.client as { fn: (args: TArgs) => ObservableInput<TResult> };
|
|
86
|
+
return firstValueFrom(from(client.fn(args)));
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Boots the real `http`, `services`, and `context` modules through the real module
|
|
91
|
+
* system, faking only the network call itself (the actual external boundary) —
|
|
92
|
+
* everything above it, including HTTP request/response handling, the services API
|
|
93
|
+
* client, the context selectors, and `ContextProvider`, runs unmocked.
|
|
94
|
+
*/
|
|
95
|
+
const addContextHttp = (configurator: ModulesConfigurator<[]>): void => {
|
|
96
|
+
configurator.addConfig(
|
|
97
|
+
configureHttp((http) => {
|
|
98
|
+
http.configureClient('context', { baseUri: 'https://context.example.com' });
|
|
99
|
+
http.addMiddleware(
|
|
100
|
+
createRouterMiddleware('https://context.example.com', (router) => {
|
|
101
|
+
router.get('/contexts/:id/relations', () =>
|
|
102
|
+
Response.json([createRawContextEntity({ id: 'ctx-3' })]),
|
|
103
|
+
);
|
|
104
|
+
router.get('/contexts', () => Response.json([createRawContextEntity({ id: 'ctx-2' })]));
|
|
105
|
+
router.get('/contexts/:id', () => Response.json(createRawContextEntity()));
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
108
|
+
}),
|
|
109
|
+
);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const initializeContextWith = async (
|
|
113
|
+
configure?: (builder: IContextModuleConfigurator) => void,
|
|
114
|
+
): Promise<ContextProvider> => {
|
|
115
|
+
const configurator = new ModulesConfigurator([]);
|
|
116
|
+
addContextHttp(configurator);
|
|
117
|
+
enableServices(configurator);
|
|
118
|
+
configurator.addConfig({ module: contextModule, configure });
|
|
119
|
+
const instances = await configurator.initialize();
|
|
120
|
+
// the configurator's generic instance map doesn't know about the context module by name
|
|
121
|
+
return (instances as unknown as { context: ContextProvider }).context;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Same as {@link initializeContextWith}, but with a recording telemetry adapter registered, so
|
|
126
|
+
* a test can assert on `Context::postInitialize.*` events/exceptions tracked through it instead
|
|
127
|
+
* of the no-telemetry `console.warn` fallback.
|
|
128
|
+
*/
|
|
129
|
+
const initializeContextWithTelemetry = async (
|
|
130
|
+
configure?: (builder: IContextModuleConfigurator) => void,
|
|
131
|
+
): Promise<{ provider: ContextProvider; adapter: MockTelemetryAdapter }> => {
|
|
132
|
+
const configurator = new ModulesConfigurator([]);
|
|
133
|
+
addContextHttp(configurator);
|
|
134
|
+
enableServices(configurator);
|
|
135
|
+
const adapter = new MockTelemetryAdapter();
|
|
136
|
+
enableTelemetryMock(configurator, (builder) => builder.setAdapter('mock', adapter));
|
|
137
|
+
configurator.addConfig({ module: contextModule, configure });
|
|
138
|
+
const instances = await configurator.initialize();
|
|
139
|
+
return {
|
|
140
|
+
// the configurator's generic instance map doesn't know about the context module by name
|
|
141
|
+
provider: (instances as unknown as { context: ContextProvider }).context,
|
|
142
|
+
adapter,
|
|
143
|
+
};
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
describe('ContextModuleConfigurator', () => {
|
|
147
|
+
describe('client defaulting', () => {
|
|
148
|
+
it('builds a client from the local services module api provider when none was configured', async () => {
|
|
149
|
+
const apiProvider = createApiProvider();
|
|
150
|
+
const configurator = new ContextModuleConfigurator();
|
|
151
|
+
|
|
152
|
+
const config = await configurator.createConfigAsync(
|
|
153
|
+
createInitArgs({
|
|
154
|
+
hasModule: vi.fn().mockReturnValue(true),
|
|
155
|
+
requireInstance: vi.fn().mockResolvedValue(apiProvider),
|
|
156
|
+
}),
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const item = await invoke(config.client.get, { id: 'ctx-1' });
|
|
160
|
+
expect(item).toMatchObject({
|
|
161
|
+
id: 'ctx-1',
|
|
162
|
+
title: 'My project',
|
|
163
|
+
type: { id: 'ProjectMaster' },
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const items = await invoke(config.client.query, { search: 'foo' });
|
|
167
|
+
expect(items).toEqual([expect.objectContaining({ id: 'ctx-2' })]);
|
|
168
|
+
|
|
169
|
+
const related = await invoke(
|
|
170
|
+
config.client.related as QueryCtorOptions<ContextItem[], unknown>,
|
|
171
|
+
{
|
|
172
|
+
item,
|
|
173
|
+
filter: undefined,
|
|
174
|
+
},
|
|
175
|
+
);
|
|
176
|
+
expect(related).toEqual([expect.objectContaining({ id: 'ctx-3' })]);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('falls back to the parent services module when not registered locally', async () => {
|
|
180
|
+
const apiProvider = createApiProvider();
|
|
181
|
+
const configurator = new ContextModuleConfigurator();
|
|
182
|
+
|
|
183
|
+
const config = await configurator.createConfigAsync(
|
|
184
|
+
createInitArgs({ ref: { services: apiProvider } }),
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
const item = await invoke(config.client.get, { id: 'ctx-1' });
|
|
188
|
+
expect(item.id).toBe('ctx-1');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('throws when no services module is available locally or on the parent', async () => {
|
|
192
|
+
const configurator = new ContextModuleConfigurator();
|
|
193
|
+
|
|
194
|
+
await expect(configurator.createConfigAsync(createInitArgs())).rejects.toThrow(
|
|
195
|
+
/no service services provider/,
|
|
196
|
+
);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('setContextClient bypasses the services module entirely', async () => {
|
|
200
|
+
const requireInstance = vi.fn();
|
|
201
|
+
const configurator = new ContextModuleConfigurator();
|
|
202
|
+
configurator.addConfigBuilder((builder) => {
|
|
203
|
+
builder.setContextClient(createMockClient());
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const config = await configurator.createConfigAsync(createInitArgs({ requireInstance }));
|
|
207
|
+
|
|
208
|
+
expect(requireInstance).not.toHaveBeenCalled();
|
|
209
|
+
const item = await invoke(config.client.get, { id: 'custom' });
|
|
210
|
+
expect(item.id).toBe('custom');
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe('config builders', () => {
|
|
215
|
+
it('applies setter values registered through addConfigBuilder', async () => {
|
|
216
|
+
const configurator = new ContextModuleConfigurator();
|
|
217
|
+
configurator.addConfigBuilder((builder) => {
|
|
218
|
+
builder.setContextType(['ProjectMaster']);
|
|
219
|
+
// keep only active context items
|
|
220
|
+
builder.setContextFilter((items) => items.filter((i) => i.isActive));
|
|
221
|
+
builder.setContextClient(createMockClient());
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const config = await configurator.createConfigAsync(createInitArgs());
|
|
225
|
+
|
|
226
|
+
expect(config.contextType).toEqual(['ProjectMaster']);
|
|
227
|
+
expect(
|
|
228
|
+
config.contextFilter?.([
|
|
229
|
+
{ isActive: true } as ContextItem,
|
|
230
|
+
{ isActive: false } as ContextItem,
|
|
231
|
+
]),
|
|
232
|
+
).toHaveLength(1);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('runs multiple config builders in order, letting later ones win', async () => {
|
|
236
|
+
const configurator = new ContextModuleConfigurator();
|
|
237
|
+
configurator.addConfigBuilder((builder) => {
|
|
238
|
+
builder.setContextType(['ProjectMaster']);
|
|
239
|
+
builder.setContextClient(createMockClient());
|
|
240
|
+
});
|
|
241
|
+
configurator.addConfigBuilder((builder) => {
|
|
242
|
+
builder.setContextType(['Facility']);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
const config = await configurator.createConfigAsync(createInitArgs());
|
|
246
|
+
|
|
247
|
+
expect(config.contextType).toEqual(['Facility']);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('exposes requireInstance to config builders once initialization has started', async () => {
|
|
251
|
+
const configurator = new ContextModuleConfigurator();
|
|
252
|
+
const requireInstance = vi.fn().mockResolvedValue('resolved-instance');
|
|
253
|
+
let resolved: unknown;
|
|
254
|
+
configurator.addConfigBuilder(async (builder) => {
|
|
255
|
+
resolved = await builder.requireInstance('event');
|
|
256
|
+
builder.setContextClient(createMockClient());
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
await configurator.createConfigAsync(createInitArgs({ requireInstance }));
|
|
260
|
+
|
|
261
|
+
expect(resolved).toBe('resolved-instance');
|
|
262
|
+
expect(requireInstance).toHaveBeenCalledWith('event');
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('requireInstance throws synchronously when called before initialization begins', () => {
|
|
266
|
+
const configurator = new ContextModuleConfigurator();
|
|
267
|
+
|
|
268
|
+
expect(() => configurator.requireInstance('event')).toThrow(
|
|
269
|
+
/requireInstance can only be called during module configuration/,
|
|
270
|
+
);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
describe('resolveInitialContext defaulting', () => {
|
|
275
|
+
it('defaults to a resolver function when a builder did not set one', async () => {
|
|
276
|
+
const configurator = new ContextModuleConfigurator();
|
|
277
|
+
configurator.addConfigBuilder((builder) => {
|
|
278
|
+
builder.setContextClient(createMockClient());
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
const config = await configurator.createConfigAsync(createInitArgs());
|
|
282
|
+
|
|
283
|
+
expect(config.resolveInitialContext).toBeTypeOf('function');
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('keeps a builder-provided resolver instead of the default', async () => {
|
|
287
|
+
const configurator = new ContextModuleConfigurator();
|
|
288
|
+
const customResolver = vi.fn();
|
|
289
|
+
configurator.addConfigBuilder((builder) => {
|
|
290
|
+
builder.setResolveInitialContext(customResolver);
|
|
291
|
+
builder.setContextClient(createMockClient());
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const config = await configurator.createConfigAsync(createInitArgs());
|
|
295
|
+
|
|
296
|
+
expect(config.resolveInitialContext).toBe(customResolver);
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
describe('ContextProvider through the real module system (http mocked at the network boundary)', () => {
|
|
302
|
+
it('switches the current context by id via the real client and selectors', async () => {
|
|
303
|
+
const provider = await initializeContextWith();
|
|
304
|
+
|
|
305
|
+
const item = await provider.setCurrentContextByIdAsync('ctx-1');
|
|
306
|
+
|
|
307
|
+
expect(item).toMatchObject({ id: 'ctx-1', title: 'My project', type: { id: 'ProjectMaster' } });
|
|
308
|
+
expect(provider.currentContext?.id).toBe('ctx-1');
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('queries context items via the real client and selectors', async () => {
|
|
312
|
+
const provider = await initializeContextWith();
|
|
313
|
+
|
|
314
|
+
const items = await provider.queryContextAsync('foo');
|
|
315
|
+
|
|
316
|
+
expect(items).toEqual([expect.objectContaining({ id: 'ctx-2' })]);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('resolves related context items via the real client and selectors', async () => {
|
|
320
|
+
const provider = await initializeContextWith();
|
|
321
|
+
|
|
322
|
+
const related = await provider.relatedContextsAsync({
|
|
323
|
+
item: { id: 'ctx-1', type: { id: 'ProjectMaster' }, value: {} },
|
|
324
|
+
filter: { type: ['ProjectMaster'] },
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
expect(related).toEqual([expect.objectContaining({ id: 'ctx-3' })]);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it('resolves an invalid context item to a related item of the configured context type', async () => {
|
|
331
|
+
const provider = await initializeContextWith((builder) => {
|
|
332
|
+
builder.setContextType(['ProjectMaster']);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// 'Contract' is not one of the configured context types, so it must be resolved
|
|
336
|
+
const resolved = await provider.resolveContextAsync({
|
|
337
|
+
id: 'ctx-contract',
|
|
338
|
+
type: { id: 'Contract' },
|
|
339
|
+
value: {},
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
expect(resolved.id).toBe('ctx-3');
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('resolves silently, without warning, when there is no initial context to resolve', async () => {
|
|
346
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
347
|
+
|
|
348
|
+
const provider = await initializeContextWith();
|
|
349
|
+
|
|
350
|
+
expect(provider.currentContext).toBeUndefined();
|
|
351
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
352
|
+
|
|
353
|
+
warnSpy.mockRestore();
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('warns (rather than silently swallowing the error) when the resolver throws and no telemetry module is registered', async () => {
|
|
357
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
358
|
+
const resolverError = new Error('boom');
|
|
359
|
+
|
|
360
|
+
const provider = await initializeContextWith((builder) => {
|
|
361
|
+
builder.setResolveInitialContext(() => Promise.reject(resolverError));
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
expect(provider.currentContext).toBeUndefined();
|
|
365
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
366
|
+
'Context::postInitialize.resolveInitialContext',
|
|
367
|
+
resolverError,
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
warnSpy.mockRestore();
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it('tracks the resolver error through telemetry (rather than console.warn) when a telemetry module is registered', async () => {
|
|
374
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
375
|
+
const resolverError = new Error('boom');
|
|
376
|
+
|
|
377
|
+
const { provider, adapter } = await initializeContextWithTelemetry((builder) => {
|
|
378
|
+
builder.setResolveInitialContext(() => Promise.reject(resolverError));
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
expect(provider.currentContext).toBeUndefined();
|
|
382
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
383
|
+
|
|
384
|
+
const [item] = adapter.getItems('Context::postInitialize.resolveInitialContext');
|
|
385
|
+
expect(item).toMatchObject({
|
|
386
|
+
name: 'Context::postInitialize.resolveInitialContext',
|
|
387
|
+
exception: resolverError,
|
|
388
|
+
level: TelemetryLevel.Warning,
|
|
389
|
+
scope: ['context', TelemetryScope.Framework],
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
warnSpy.mockRestore();
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it('tracks a resolved initial context as a telemetry event', async () => {
|
|
396
|
+
const { provider, adapter } = await initializeContextWithTelemetry((builder) => {
|
|
397
|
+
builder.setResolveInitialContext(() =>
|
|
398
|
+
Promise.resolve({ id: 'ctx-1', type: { id: 'ProjectMaster' }, value: {} }),
|
|
399
|
+
);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
expect(provider.currentContext?.id).toBe('ctx-1');
|
|
403
|
+
|
|
404
|
+
const [item] = adapter.getItems('Context::postInitialize.initialContextResolved');
|
|
405
|
+
expect(item).toMatchObject({
|
|
406
|
+
name: 'Context::postInitialize.initialContextResolved',
|
|
407
|
+
level: TelemetryLevel.Debug,
|
|
408
|
+
scope: ['context', TelemetryScope.Framework],
|
|
409
|
+
properties: { contextId: 'ctx-1' },
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { ModulesConfigurator } from '@equinor/fusion-framework-module';
|
|
4
|
+
|
|
5
|
+
import type { ContextProvider } from '../../ContextProvider';
|
|
6
|
+
import { module as realModule } from '../../module';
|
|
7
|
+
import type { ContextItem } from '../../types';
|
|
8
|
+
import { ContextMockConfigurator, contextMockModule, enableContextMock } from '../../mock';
|
|
9
|
+
import { createContextItemFactory, createContextItems } from '../../mock/fixtures';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Initializes the mock module through the real module system.
|
|
13
|
+
*
|
|
14
|
+
* @remarks
|
|
15
|
+
* Deliberately avoids hand-building initialization arguments — the module
|
|
16
|
+
* system itself is the thing under test, not a fake of it.
|
|
17
|
+
*
|
|
18
|
+
* @param configure - Callback to seed context items or override resolution.
|
|
19
|
+
* @returns The provider the module produced.
|
|
20
|
+
*/
|
|
21
|
+
const initializeMockWith = async (
|
|
22
|
+
configure?: (mock: ContextMockConfigurator) => void,
|
|
23
|
+
): Promise<ContextProvider> => {
|
|
24
|
+
const configurator = new ModulesConfigurator([]);
|
|
25
|
+
enableContextMock(configurator, configure);
|
|
26
|
+
const instances = await configurator.initialize();
|
|
27
|
+
// the configurator's generic instance map doesn't know about the context module by name
|
|
28
|
+
return (instances as unknown as { context: ContextProvider }).context;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
describe('contextMockModule', () => {
|
|
32
|
+
it('changes nothing but the configurator', () => {
|
|
33
|
+
expect(contextMockModule.name).toBe(realModule.name);
|
|
34
|
+
expect(contextMockModule.initialize).toBe(realModule.initialize);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('builds a real ContextModuleConfigurator, so the whole builder stays available', () => {
|
|
38
|
+
const configurator = contextMockModule.configure?.();
|
|
39
|
+
|
|
40
|
+
expect(configurator).toBeInstanceOf(ContextMockConfigurator);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('enableContextMock', () => {
|
|
45
|
+
it('initializes with no current context when nothing is seeded', async () => {
|
|
46
|
+
const provider = await initializeMockWith();
|
|
47
|
+
|
|
48
|
+
expect(provider.currentContext).toBeFalsy();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('selects a seeded item as the initial context, without a navigation or parent module', async () => {
|
|
52
|
+
const [project] = createContextItems([{ type: 'ProjectMaster' }]);
|
|
53
|
+
|
|
54
|
+
const provider = await initializeMockWith((mock) => mock.setCurrentContext(project));
|
|
55
|
+
|
|
56
|
+
expect(provider.currentContext?.id).toBe(project.id);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('resolves a seeded item by id', async () => {
|
|
60
|
+
const [project] = createContextItems([{ type: 'ProjectMaster' }]);
|
|
61
|
+
|
|
62
|
+
const provider = await initializeMockWith((mock) => mock.setContexts([project]));
|
|
63
|
+
const resolved = await provider.setCurrentContextByIdAsync(project.id);
|
|
64
|
+
|
|
65
|
+
expect(resolved.id).toBe(project.id);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('rejects with a clear error for an unseeded id', async () => {
|
|
69
|
+
const provider = await initializeMockWith();
|
|
70
|
+
|
|
71
|
+
await expect(provider.setCurrentContextByIdAsync('missing')).rejects.toThrow(
|
|
72
|
+
/no context item resolves for id "missing"/,
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('resolves related contexts by filtering the seeded pool by type', async () => {
|
|
77
|
+
const [project] = createContextItems([{ type: 'ProjectMaster' }]);
|
|
78
|
+
const [contract] = createContextItems([{ type: 'Contract', parentTypeIds: ['ProjectMaster'] }]);
|
|
79
|
+
|
|
80
|
+
const provider = await initializeMockWith((mock) => mock.setContexts([project, contract]));
|
|
81
|
+
const related = await provider.relatedContextsAsync({
|
|
82
|
+
item: contract,
|
|
83
|
+
filter: { type: ['ProjectMaster'] },
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(related).toEqual([project]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('resolves a child-typed item into its configured parent type', async () => {
|
|
90
|
+
const [project] = createContextItems([{ type: 'ProjectMaster' }]);
|
|
91
|
+
const [contract] = createContextItems([{ type: 'Contract', parentTypeIds: ['ProjectMaster'] }]);
|
|
92
|
+
|
|
93
|
+
const provider = await initializeMockWith((mock) => {
|
|
94
|
+
// the provider only calls resolveContext for a type it wasn't configured to accept
|
|
95
|
+
mock.addConfigBuilder((builder) => {
|
|
96
|
+
builder.setContextType(['ProjectMaster']);
|
|
97
|
+
});
|
|
98
|
+
mock.setContexts([project, contract]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const resolved = await provider.resolveContextAsync(contract);
|
|
102
|
+
|
|
103
|
+
expect(resolved.id).toBe(project.id);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('overrides the default type-filtered related contexts for one specific item', async () => {
|
|
107
|
+
const createContract = createContextItemFactory('contract');
|
|
108
|
+
const contractA = createContract({
|
|
109
|
+
type: { id: 'Contract', isChildType: true, parentTypeIds: ['ProjectMaster'] },
|
|
110
|
+
});
|
|
111
|
+
const contractB = createContract({
|
|
112
|
+
type: { id: 'Contract', isChildType: true, parentTypeIds: ['ProjectMaster'] },
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const provider = await initializeMockWith((mock) => {
|
|
116
|
+
mock.setContexts([contractA, contractB]);
|
|
117
|
+
mock.setRelatedContexts(contractA.id, [contractB]);
|
|
118
|
+
});
|
|
119
|
+
const related = await provider.relatedContextsAsync({
|
|
120
|
+
item: contractA,
|
|
121
|
+
filter: { type: ['Contract'] },
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
expect(related).toEqual([contractB]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('resolves through the escape hatch when set', async () => {
|
|
128
|
+
const special: ContextItem = { id: 'special', type: { id: 'ProjectMaster' }, value: {} };
|
|
129
|
+
|
|
130
|
+
const provider = await initializeMockWith((mock) =>
|
|
131
|
+
mock.setResolver((id) => (id === 'special' ? special : undefined)),
|
|
132
|
+
);
|
|
133
|
+
const resolved = await provider.setCurrentContextByIdAsync('special');
|
|
134
|
+
|
|
135
|
+
expect(resolved).toEqual(special);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { createContextItemFactory } from '../../mock/fixtures';
|
|
4
|
+
|
|
5
|
+
describe('createContextItemFactory', () => {
|
|
6
|
+
it('generates sequential, deterministic ids across calls', () => {
|
|
7
|
+
const createContextItem = createContextItemFactory();
|
|
8
|
+
|
|
9
|
+
expect(createContextItem().id).toBe('ctx-1');
|
|
10
|
+
expect(createContextItem().id).toBe('ctx-2');
|
|
11
|
+
expect(createContextItem().id).toBe('ctx-3');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('scopes the sequence to the returned factory, not globally', () => {
|
|
15
|
+
const a = createContextItemFactory();
|
|
16
|
+
const b = createContextItemFactory();
|
|
17
|
+
|
|
18
|
+
a();
|
|
19
|
+
a();
|
|
20
|
+
|
|
21
|
+
// b starts counting from 1 regardless of how many items a() already created
|
|
22
|
+
expect(b().id).toBe('ctx-1');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('uses the given prefix', () => {
|
|
26
|
+
const createContextItem = createContextItemFactory('project');
|
|
27
|
+
|
|
28
|
+
expect(createContextItem().id).toBe('project-1');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('applies overrides on top of the defaults', () => {
|
|
32
|
+
const createContextItem = createContextItemFactory();
|
|
33
|
+
|
|
34
|
+
const item = createContextItem({ title: 'Project 42', type: { id: 'ProjectMaster' } });
|
|
35
|
+
|
|
36
|
+
expect(item.title).toBe('Project 42');
|
|
37
|
+
expect(item.type).toEqual({ id: 'ProjectMaster' });
|
|
38
|
+
expect(item.value).toEqual({});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('lets an explicit id override the generator without breaking the sequence', () => {
|
|
42
|
+
const createContextItem = createContextItemFactory();
|
|
43
|
+
|
|
44
|
+
const first = createContextItem({ id: 'explicit-id' });
|
|
45
|
+
const second = createContextItem();
|
|
46
|
+
|
|
47
|
+
expect(first.id).toBe('explicit-id');
|
|
48
|
+
// the counter still advances, even though the first call didn't consume a generated id
|
|
49
|
+
expect(second.id).toBe('ctx-2');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('fills in a deterministic default title from a faker instance seeded by the item id', () => {
|
|
53
|
+
// two independent factories generating the same id must produce the same title
|
|
54
|
+
const titleA = createContextItemFactory()().title;
|
|
55
|
+
const titleB = createContextItemFactory()().title;
|
|
56
|
+
|
|
57
|
+
expect(titleA).toEqual(titleB);
|
|
58
|
+
expect(titleA).toEqual(expect.any(String));
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { createContextItems } from '../../mock/fixtures';
|
|
4
|
+
|
|
5
|
+
describe('createContextItems', () => {
|
|
6
|
+
it('generates one item per type by default, with deterministic ids', () => {
|
|
7
|
+
const [project] = createContextItems([{ type: 'ProjectMaster' }]);
|
|
8
|
+
|
|
9
|
+
expect(project.id).toBe('projectmaster-1');
|
|
10
|
+
expect(project.type).toEqual({ id: 'ProjectMaster' });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('generates `count` items per type, numbered from 1', () => {
|
|
14
|
+
const items = createContextItems([{ type: 'Facility', count: 3 }]);
|
|
15
|
+
|
|
16
|
+
// compare ids only, to assert the sequence without repeating every field
|
|
17
|
+
expect(items.map((item) => item.id)).toEqual(['facility-1', 'facility-2', 'facility-3']);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('generates items for several types in the order given', () => {
|
|
21
|
+
const items = createContextItems([{ type: 'ProjectMaster' }, { type: 'Facility', count: 2 }]);
|
|
22
|
+
|
|
23
|
+
// compare ids only, to assert both type and generation order together
|
|
24
|
+
expect(items.map((item) => item.id)).toEqual(['projectmaster-1', 'facility-1', 'facility-2']);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('marks a type as a child type when parentTypeIds is given', () => {
|
|
28
|
+
const [facility] = createContextItems([{ type: 'Facility', parentTypeIds: ['ProjectMaster'] }]);
|
|
29
|
+
|
|
30
|
+
expect(facility.type).toEqual({
|
|
31
|
+
id: 'Facility',
|
|
32
|
+
isChildType: true,
|
|
33
|
+
parentTypeIds: ['ProjectMaster'],
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('applies per-item overrides by 1-based index', () => {
|
|
38
|
+
const items = createContextItems([
|
|
39
|
+
{
|
|
40
|
+
type: 'Facility',
|
|
41
|
+
count: 2,
|
|
42
|
+
item: (index) => ({ title: `Facility ${index}` }),
|
|
43
|
+
},
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
// compare titles only, to assert the override was applied per index
|
|
47
|
+
expect(items.map((item) => item.title)).toEqual(['Facility 1', 'Facility 2']);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('fills in a deterministic default title from a faker instance seeded by the item id', () => {
|
|
51
|
+
const [a] = createContextItems([{ type: 'ProjectMaster' }]);
|
|
52
|
+
const [b] = createContextItems([{ type: 'ProjectMaster' }]);
|
|
53
|
+
|
|
54
|
+
// same id ('projectmaster-1') in both calls must produce the same title
|
|
55
|
+
expect(a.title).toEqual(b.title);
|
|
56
|
+
expect(a.title).toEqual(expect.any(String));
|
|
57
|
+
});
|
|
58
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -11,11 +11,9 @@
|
|
|
11
11
|
* @packageDocumentation
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
export {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
ContextModuleConfig,
|
|
18
|
-
} from './configurator';
|
|
14
|
+
export { ContextModuleConfigurator } from './ContextModuleConfigurator';
|
|
15
|
+
export type { IContextModuleConfigurator } from './ContextModuleConfigurator.interface';
|
|
16
|
+
export type { ContextModuleConfig } from './ContextModuleConfig';
|
|
19
17
|
|
|
20
18
|
export { IContextProvider, ContextProvider } from './ContextProvider';
|
|
21
19
|
|