@cratis/chronicle 6.34.0 → 6.36.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/ChronicleClient.ts +8 -2
- package/EventStore.ts +8 -0
- package/dist/ChronicleClient.d.ts.map +1 -1
- package/dist/ChronicleClient.js +10 -2
- package/dist/ChronicleClient.js.map +1 -1
- package/dist/EventStore.d.ts.map +1 -1
- package/dist/EventStore.js +8 -0
- package/dist/EventStore.js.map +1 -1
- package/dist/events/handles.d.ts +12 -0
- package/dist/events/handles.d.ts.map +1 -0
- package/dist/events/handles.js +33 -0
- package/dist/events/handles.js.map +1 -0
- package/dist/events/index.d.ts +1 -0
- package/dist/events/index.d.ts.map +1 -1
- package/dist/events/index.js +1 -0
- package/dist/events/index.js.map +1 -1
- package/dist/observation/EventHandler.d.ts +7 -0
- package/dist/observation/EventHandler.d.ts.map +1 -0
- package/dist/observation/EventHandler.js +4 -0
- package/dist/observation/EventHandler.js.map +1 -0
- package/dist/observation/createHandlerInstanceValidator.d.ts +4 -0
- package/dist/observation/createHandlerInstanceValidator.d.ts.map +1 -0
- package/dist/observation/createHandlerInstanceValidator.js +40 -0
- package/dist/observation/createHandlerInstanceValidator.js.map +1 -0
- package/dist/observation/getEventHandlers.d.ts +5 -0
- package/dist/observation/getEventHandlers.d.ts.map +1 -0
- package/dist/observation/getEventHandlers.js +40 -0
- package/dist/observation/getEventHandlers.js.map +1 -0
- package/dist/observation/getInstanceMethods.d.ts +4 -0
- package/dist/observation/getInstanceMethods.d.ts.map +1 -0
- package/dist/observation/getInstanceMethods.js +29 -0
- package/dist/observation/getInstanceMethods.js.map +1 -0
- package/dist/reactors/ReactorDispatcher.d.ts.map +1 -1
- package/dist/reactors/ReactorDispatcher.js +27 -32
- package/dist/reactors/ReactorDispatcher.js.map +1 -1
- package/dist/reactors/Reactors.d.ts +1 -0
- package/dist/reactors/Reactors.d.ts.map +1 -1
- package/dist/reactors/Reactors.js +27 -14
- package/dist/reactors/Reactors.js.map +1 -1
- package/dist/reactors/index.d.ts +1 -0
- package/dist/reactors/index.d.ts.map +1 -1
- package/dist/reactors/index.js +1 -0
- package/dist/reactors/index.js.map +1 -1
- package/dist/reducers/ReducerEventDispatcher.d.ts +1 -0
- package/dist/reducers/ReducerEventDispatcher.d.ts.map +1 -1
- package/dist/reducers/ReducerEventDispatcher.js +5 -17
- package/dist/reducers/ReducerEventDispatcher.js.map +1 -1
- package/dist/reducers/ReducerEventHandler.d.ts +3 -6
- package/dist/reducers/ReducerEventHandler.d.ts.map +1 -1
- package/dist/reducers/Reducers.d.ts +1 -0
- package/dist/reducers/Reducers.d.ts.map +1 -1
- package/dist/reducers/Reducers.js +22 -10
- package/dist/reducers/Reducers.js.map +1 -1
- package/dist/reducers/index.d.ts +1 -0
- package/dist/reducers/index.d.ts.map +1 -1
- package/dist/reducers/index.js +1 -0
- package/dist/reducers/index.js.map +1 -1
- package/dist/testing/ReactorScenario.d.ts +1 -0
- package/dist/testing/ReactorScenario.d.ts.map +1 -1
- package/dist/testing/ReactorScenario.js +9 -1
- package/dist/testing/ReactorScenario.js.map +1 -1
- package/dist/testing/ReducerReadModelProcessor.d.ts.map +1 -1
- package/dist/testing/ReducerReadModelProcessor.js +1 -0
- package/dist/testing/ReducerReadModelProcessor.js.map +1 -1
- package/dist/testing/projections/ProjectionArithmetic.d.ts +11 -0
- package/dist/testing/projections/ProjectionArithmetic.d.ts.map +1 -0
- package/dist/testing/projections/ProjectionArithmetic.js +70 -0
- package/dist/testing/projections/ProjectionArithmetic.js.map +1 -0
- package/dist/testing/projections/ProjectionCapabilities.d.ts.map +1 -1
- package/dist/testing/projections/ProjectionCapabilities.js +26 -10
- package/dist/testing/projections/ProjectionCapabilities.js.map +1 -1
- package/dist/testing/projections/ProjectionReadModelProcessor.d.ts.map +1 -1
- package/dist/testing/projections/ProjectionReadModelProcessor.js +10 -3
- package/dist/testing/projections/ProjectionReadModelProcessor.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/events/for_handles/when_decorating_a_method.legacy.spec.ts +91 -0
- package/events/for_handles/when_decorating_a_method.standard.spec.ts +102 -0
- package/events/handles.ts +37 -0
- package/events/index.ts +1 -0
- package/for_ChronicleClient/when_registering_artifacts/with_invalid_handler_mappings.spec.ts +142 -0
- package/observation/EventHandler.ts +9 -0
- package/observation/createHandlerInstanceValidator.ts +42 -0
- package/observation/for_getEventHandlers/when_discovering_handlers.spec.ts +137 -0
- package/observation/for_getEventHandlers/when_reobserving_explicit_handlers.spec.ts +73 -0
- package/observation/getEventHandlers.ts +43 -0
- package/observation/getInstanceMethods.ts +29 -0
- package/package.json +2 -2
- package/reactors/ReactorDispatcher.ts +26 -30
- package/reactors/Reactors.ts +32 -16
- package/reactors/decorators.standard.spec.ts +8 -0
- package/reactors/for_ReactorDispatcher/when_selecting_explicit_handlers.spec.ts +64 -0
- package/reactors/for_Reactors/when_activating_a_field_override.spec.ts +62 -0
- package/reactors/for_Reactors/when_delivering_to_instance_handlers.spec.ts +108 -0
- package/reactors/for_Reactors/when_observing_explicit_handlers.legacy.spec.ts +77 -0
- package/reactors/for_Reactors/when_registering_explicit_handlers.spec.ts +53 -0
- package/reactors/index.ts +1 -0
- package/reducers/ReducerEventDispatcher.ts +5 -16
- package/reducers/ReducerEventHandler.ts +4 -6
- package/reducers/Reducers.ts +24 -12
- package/reducers/for_ReducerEventDispatcher/when_invoking_an_explicit_handler.spec.ts +51 -0
- package/reducers/for_ReducerEventDispatcher/when_invoking_instance_handlers.spec.ts +69 -0
- package/reducers/for_Reducers/when_activating_a_field_override.spec.ts +63 -0
- package/reducers/for_Reducers/when_observing_explicit_handlers.legacy.spec.ts +73 -0
- package/reducers/for_Reducers/when_registering_explicit_handlers.spec.ts +57 -0
- package/reducers/index.ts +1 -0
- package/testing/ReactorScenario.ts +9 -1
- package/testing/ReducerReadModelProcessor.ts +1 -0
- package/testing/for_ReactorScenario/when_delivering_to_an_explicit_handler.spec.ts +100 -0
- package/testing/for_ReadModelScenario/when_evaluating_arithmetic/with_a_fluent_projection.spec.ts +85 -0
- package/testing/for_ReadModelScenario/when_evaluating_arithmetic/with_legacy_decorators.legacy.spec.ts +91 -0
- package/testing/for_ReadModelScenario/when_evaluating_arithmetic/with_standard_decorators.standard.spec.ts +92 -0
- package/testing/for_ReducerReadModelProcessor/when_an_instance_field_hides_a_handler.spec.ts +29 -0
- package/testing/projections/ProjectionArithmetic.ts +71 -0
- package/testing/projections/ProjectionCapabilities.ts +27 -9
- package/testing/projections/ProjectionReadModelProcessor.fixtures.spec.ts +9 -6
- package/testing/projections/ProjectionReadModelProcessor.ts +9 -3
- package/testing/projections/for_ProjectionCapabilities/when_rejecting_unsupported_operations.spec.ts +3 -3
- package/testing/projections/for_ProjectionCapabilities/when_validating_arithmetic.spec.ts +127 -0
- package/testing/projections/for_ProjectionCapabilities/when_validating_numeric_schemas.spec.ts +4 -5
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
import { beforeEach, chai, describe, it } from 'vitest';
|
|
5
|
+
import { eventType } from '../../events/eventTypeDecorator.js';
|
|
6
|
+
import { EventType } from '../../events/EventType.js';
|
|
7
|
+
import type { EventContext } from '../../events/EventContext.js';
|
|
8
|
+
import { handles } from '../index.js';
|
|
9
|
+
import { ReducerEventDispatcher } from '../ReducerEventDispatcher.js';
|
|
10
|
+
|
|
11
|
+
chai.should();
|
|
12
|
+
|
|
13
|
+
@eventType('instance-reducer-event')
|
|
14
|
+
class AuthorRegistered { constructor(readonly name: string) {} }
|
|
15
|
+
|
|
16
|
+
class BoundConventional {
|
|
17
|
+
readonly prefix = 'conventional';
|
|
18
|
+
constructor() { this.authorRegistered = this.authorRegistered.bind(this); }
|
|
19
|
+
authorRegistered(event: AuthorRegistered, state: string[] | undefined, context: EventContext) {
|
|
20
|
+
return [...(state ?? []), `${this.prefix}:${event.name}:${context.eventSourceId}`];
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
class BoundExplicit {
|
|
25
|
+
readonly prefix = 'explicit';
|
|
26
|
+
constructor() { this.update = this.update.bind(this); }
|
|
27
|
+
@handles(AuthorRegistered)
|
|
28
|
+
update(event: AuthorRegistered, state: string[] | undefined, context: EventContext) {
|
|
29
|
+
return [...(state ?? []), `${this.prefix}:${event.name}:${context.eventSourceId}`];
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
class Conventional {
|
|
34
|
+
authorRegistered(event: AuthorRegistered, state: string[] | undefined, context: EventContext) {
|
|
35
|
+
return [...(state ?? []), `base:${event.name}:${context.eventSourceId}`];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
class FieldConventional extends Conventional {
|
|
40
|
+
override authorRegistered = (event: AuthorRegistered, state: string[] | undefined, context: EventContext) =>
|
|
41
|
+
[...(state ?? []), `field:${event.name}:${context.eventSourceId}`];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
for (const { type, prefix } of [
|
|
45
|
+
{ type: BoundConventional, prefix: 'conventional' },
|
|
46
|
+
{ type: BoundExplicit, prefix: 'explicit' },
|
|
47
|
+
{ type: FieldConventional, prefix: 'field' }
|
|
48
|
+
]) {
|
|
49
|
+
describe(`when invoking the ${prefix} reducer instance handler`, () => {
|
|
50
|
+
let result: unknown;
|
|
51
|
+
beforeEach(async () => {
|
|
52
|
+
const dispatcher = new ReducerEventDispatcher(type, [AuthorRegistered]);
|
|
53
|
+
const instance = new type();
|
|
54
|
+
dispatcher.validateInstance(instance);
|
|
55
|
+
const context: EventContext = {
|
|
56
|
+
sequenceNumber: 1n, eventSourceId: 'author', eventType: EventType.parse('instance-reducer-event'),
|
|
57
|
+
occurred: new Date(), correlationId: '', causation: [], tags: []
|
|
58
|
+
};
|
|
59
|
+
const handler = dispatcher.handlerFor('instance-reducer-event')!;
|
|
60
|
+
const initial = await dispatcher.invoke(instance as unknown as Record<string, Function>, handler,
|
|
61
|
+
new AuthorRegistered('Ada'), undefined, context);
|
|
62
|
+
result = await dispatcher.invoke(instance as unknown as Record<string, Function>, handler,
|
|
63
|
+
new AuthorRegistered('Grace'), initial, context);
|
|
64
|
+
});
|
|
65
|
+
it('should invoke the instance handler with the event state and context', () => {
|
|
66
|
+
(result as string[]).should.deep.equal([`${prefix}:Ada:author`, `${prefix}:Grace:author`]);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
import { EventContext, EventType, ObservationState, ReplayState, type ReducerMessage } from '@cratis/chronicle.contracts';
|
|
5
|
+
import { beforeEach, chai, describe, it } from 'vitest';
|
|
6
|
+
import type { IClientArtifactsProvider } from '../../artifacts/index.js';
|
|
7
|
+
import type { ChronicleConnection } from '../../connection/index.js';
|
|
8
|
+
import { ConnectionLifecycle } from '../../connection/ConnectionLifecycle.js';
|
|
9
|
+
import type { IEventStore } from '../../IEventStore.js';
|
|
10
|
+
import { eventType } from '../../events/eventTypeDecorator.js';
|
|
11
|
+
import { handles, reducer, Reducers } from '../index.js';
|
|
12
|
+
|
|
13
|
+
chai.should();
|
|
14
|
+
|
|
15
|
+
@eventType('field-reducer-event')
|
|
16
|
+
class AuthorRegistered {}
|
|
17
|
+
const calls: string[] = [];
|
|
18
|
+
class Base {
|
|
19
|
+
@handles(AuthorRegistered) update() { calls.push('base'); return {}; }
|
|
20
|
+
beginReplay() { calls.push('replay'); }
|
|
21
|
+
endReplay() {}
|
|
22
|
+
}
|
|
23
|
+
@reducer('field-reducer')
|
|
24
|
+
class Derived extends Base {
|
|
25
|
+
override update = () => { calls.push('field'); return {}; };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
for (const replayState of [ReplayState.REPLAY_STATE_None, ReplayState.BeginReplay]) {
|
|
29
|
+
describe(`when activating a reducer with a field override for replay state ${replayState}`, () => {
|
|
30
|
+
let result: ReducerMessage;
|
|
31
|
+
let disposed: boolean;
|
|
32
|
+
beforeEach(async () => {
|
|
33
|
+
calls.length = 0;
|
|
34
|
+
disposed = false;
|
|
35
|
+
const lifecycle = new ConnectionLifecycle();
|
|
36
|
+
let finish!: () => void;
|
|
37
|
+
const finished = new Promise<void>(resolve => { finish = resolve; });
|
|
38
|
+
async function* stream(queue: AsyncIterable<ReducerMessage>) {
|
|
39
|
+
const messages = queue[Symbol.asyncIterator]();
|
|
40
|
+
await messages.next();
|
|
41
|
+
yield { Events: [{ Content: '{}', Context: EventContext.fromPartial({
|
|
42
|
+
EventType: EventType.fromPartial({ Id: 'field-reducer-event', Generation: 1 }), SequenceNumber: 1n
|
|
43
|
+
}) }], Partition: 'author', ReplayState: replayState, InitialState: '' };
|
|
44
|
+
result = (await messages.next()).value!;
|
|
45
|
+
await lifecycle.disconnected(error => { throw error; });
|
|
46
|
+
finish();
|
|
47
|
+
}
|
|
48
|
+
const reducers = new Reducers({ reducers: [Derived], eventTypes: [AuthorRegistered], readModels: [] } as unknown as IClientArtifactsProvider,
|
|
49
|
+
{ reducers: { observe: stream }, readModels: { registerMany: async () => ({}) } } as unknown as ChronicleConnection,
|
|
50
|
+
'store', 'tenant', lifecycle, 'sink', { readModels: {} } as IEventStore,
|
|
51
|
+
type => ({ instance: new type(), dispose: () => { disposed = true; } }));
|
|
52
|
+
try { await reducers.register(); await finished; } finally { reducers.dispose(); }
|
|
53
|
+
});
|
|
54
|
+
it('should fail delivery without invoking the field or replay notification', () => {
|
|
55
|
+
calls.should.have.lengthOf(0);
|
|
56
|
+
result.Content!.Value1!.State.should.equal(ObservationState.Failed);
|
|
57
|
+
result.Content!.Value1!.ReadModelState.should.equal('');
|
|
58
|
+
result.Content!.Value1!.ExceptionMessages.join(' ').should.contain(
|
|
59
|
+
"Override 'update' on 'Derived' hides @handles(AuthorRegistered) declared on 'Base'; use a method with @handles instead of an instance field.");
|
|
60
|
+
});
|
|
61
|
+
it('should release the rejected activation lease', () => { disposed.should.be.true; });
|
|
62
|
+
});
|
|
63
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
import { EventContext, EventType, ObservationState, type ReducerMessage } from '@cratis/chronicle.contracts';
|
|
5
|
+
import { beforeEach, chai, describe, it } from 'vitest';
|
|
6
|
+
import type { IClientArtifactsProvider } from '../../artifacts/index.js';
|
|
7
|
+
import type { ChronicleConnection } from '../../connection/index.js';
|
|
8
|
+
import { ConnectionLifecycle } from '../../connection/ConnectionLifecycle.js';
|
|
9
|
+
import { eventType } from '../../events/eventTypeDecorator.js';
|
|
10
|
+
import type { EventContext as ClientEventContext } from '../../events/EventContext.js';
|
|
11
|
+
import { handles, reducer, Reducers } from '../index.js';
|
|
12
|
+
|
|
13
|
+
chai.should();
|
|
14
|
+
|
|
15
|
+
@eventType('legacy-reduced-handler')
|
|
16
|
+
class AuthorRegistered { constructor(readonly name = '') {} }
|
|
17
|
+
|
|
18
|
+
@reducer('legacy-reduction')
|
|
19
|
+
class AuthorReducer {
|
|
20
|
+
@handles(AuthorRegistered)
|
|
21
|
+
update(event: AuthorRegistered, state: { names: string[] } | undefined, context: ClientEventContext) {
|
|
22
|
+
return { names: [...(state?.names ?? []), event.name], id: context.eventSourceId };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('when observing explicit legacy reducer handlers', () => {
|
|
27
|
+
let registration: ReducerMessage;
|
|
28
|
+
let result: ReducerMessage;
|
|
29
|
+
beforeEach(async () => {
|
|
30
|
+
const lifecycle = new ConnectionLifecycle();
|
|
31
|
+
let finish!: () => void;
|
|
32
|
+
const finished = new Promise<void>(resolve => { finish = resolve; });
|
|
33
|
+
async function* stream(queue: AsyncIterable<ReducerMessage>) {
|
|
34
|
+
const messages = queue[Symbol.asyncIterator]();
|
|
35
|
+
registration = (await messages.next()).value!;
|
|
36
|
+
yield {
|
|
37
|
+
Events: ['Ada', 'Grace'].map((name, index) => ({
|
|
38
|
+
Context: EventContext.fromPartial({
|
|
39
|
+
EventType: EventType.fromPartial({ Id: 'legacy-reduced-handler', Generation: 1 }),
|
|
40
|
+
EventSourceId: 'author', SequenceNumber: BigInt(index + 1)
|
|
41
|
+
}),
|
|
42
|
+
Content: JSON.stringify({ name })
|
|
43
|
+
})),
|
|
44
|
+
Partition: 'author', ReplayState: 0, InitialState: ''
|
|
45
|
+
};
|
|
46
|
+
result = (await messages.next()).value!;
|
|
47
|
+
await lifecycle.disconnected(error => { throw error; });
|
|
48
|
+
finish();
|
|
49
|
+
}
|
|
50
|
+
const connection = {
|
|
51
|
+
reducers: { observe: stream }, readModels: { registerMany: async () => ({}) }
|
|
52
|
+
} as unknown as ChronicleConnection;
|
|
53
|
+
const artifacts = {
|
|
54
|
+
reducers: [AuthorReducer], eventTypes: [AuthorRegistered], readModels: []
|
|
55
|
+
} as unknown as IClientArtifactsProvider;
|
|
56
|
+
const reducers = new Reducers(artifacts, connection, 'store', 'tenant', lifecycle, 'sink');
|
|
57
|
+
try {
|
|
58
|
+
await reducers.register();
|
|
59
|
+
await finished;
|
|
60
|
+
} finally { reducers.dispose(); }
|
|
61
|
+
});
|
|
62
|
+
it('should subscribe to the explicitly named event type', () => {
|
|
63
|
+
registration.Content!.Value0!.Reducer!.EventTypes.map(entry => entry.EventType!.Id)
|
|
64
|
+
.should.deep.equal(['legacy-reduced-handler']);
|
|
65
|
+
});
|
|
66
|
+
it('should pass the event state and context through the named method', () => {
|
|
67
|
+
JSON.parse(result.Content!.Value1!.ReadModelState).should.deep.equal({ names: ['Ada', 'Grace'], id: 'author' });
|
|
68
|
+
});
|
|
69
|
+
it('should acknowledge both deliveries successfully', () => {
|
|
70
|
+
result.Content!.Value1!.State.should.equal(ObservationState.Success);
|
|
71
|
+
result.Content!.Value1!.LastSuccessfulObservation.should.equal(2n);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
import { beforeEach, chai, describe, it, vi } from 'vitest';
|
|
5
|
+
import type { IClientArtifactsProvider } from '../../artifacts/index.js';
|
|
6
|
+
import type { ChronicleConnection } from '../../connection/index.js';
|
|
7
|
+
import { ConnectionLifecycle } from '../../connection/ConnectionLifecycle.js';
|
|
8
|
+
import { eventType } from '../../events/eventTypeDecorator.js';
|
|
9
|
+
import { handles, reducer, Reducers } from '../index.js';
|
|
10
|
+
|
|
11
|
+
chai.should();
|
|
12
|
+
|
|
13
|
+
@eventType('reducer-handled-event')
|
|
14
|
+
class AuthorRegistered {}
|
|
15
|
+
@eventType('unselected-reducer-event')
|
|
16
|
+
class Unregistered {}
|
|
17
|
+
|
|
18
|
+
@reducer('valid-handlers')
|
|
19
|
+
class Valid { @handles(AuthorRegistered) update() {} }
|
|
20
|
+
@reducer('missing-handler-event')
|
|
21
|
+
class Missing { @handles(Unregistered) update() {} }
|
|
22
|
+
@reducer('duplicate-handlers')
|
|
23
|
+
class Duplicate {
|
|
24
|
+
@handles(AuthorRegistered) update() {}
|
|
25
|
+
authorRegistered() {}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
for (const [type, message] of [
|
|
29
|
+
[Missing, /update.*Missing.*Unregistered.*no registered event type/],
|
|
30
|
+
[Duplicate, /multiple handlers.*reducer-handled-event.*update.*authorRegistered/]
|
|
31
|
+
] as const) {
|
|
32
|
+
describe(`when registering a reducer with invalid explicit handlers on ${type.name}`, () => {
|
|
33
|
+
const observe = vi.fn();
|
|
34
|
+
const registerMany = vi.fn();
|
|
35
|
+
let error: unknown;
|
|
36
|
+
beforeEach(async () => {
|
|
37
|
+
observe.mockReset();
|
|
38
|
+
registerMany.mockReset();
|
|
39
|
+
error = undefined;
|
|
40
|
+
const artifacts = { reducers: [Valid, type], eventTypes: [AuthorRegistered], readModels: [] } as unknown as IClientArtifactsProvider;
|
|
41
|
+
const connection = { reducers: { observe }, readModels: { registerMany } } as unknown as ChronicleConnection;
|
|
42
|
+
const reducers = new Reducers(artifacts, connection, 'store', 'tenant', new ConnectionLifecycle(), 'sink');
|
|
43
|
+
try { await reducers.register(); } catch (caught) { error = caught; }
|
|
44
|
+
finally { reducers.dispose(); }
|
|
45
|
+
});
|
|
46
|
+
it('should reject registration with an actionable error', () => {
|
|
47
|
+
(error instanceof Error).should.be.true;
|
|
48
|
+
(error as Error).message.should.match(message);
|
|
49
|
+
});
|
|
50
|
+
it('should not register read models before validating all handlers', () => {
|
|
51
|
+
registerMany.mock.calls.should.have.lengthOf(0);
|
|
52
|
+
});
|
|
53
|
+
it('should not start an observation before validating all reducers', () => {
|
|
54
|
+
observe.mock.calls.should.have.lengthOf(0);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
}
|
package/reducers/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Copyright (c) Cratis. All rights reserved.
|
|
2
2
|
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
3
|
|
|
4
|
+
export { handles } from '../events/handles.js';
|
|
4
5
|
export { ReducerId } from './ReducerId.js';
|
|
5
6
|
export { reducer, getReducerMetadata, isReducer } from './reducer.js';
|
|
6
7
|
export type { ReducerMetadata } from './reducer.js';
|
|
@@ -21,6 +21,7 @@ import { EventStoreNamespaceName } from '../EventStoreNamespaceName.js';
|
|
|
21
21
|
import type { IEventStore } from '../IEventStore.js';
|
|
22
22
|
import type { IReadModels } from '../readModels/IReadModels.js';
|
|
23
23
|
import { getReactorEventTypes, invokeReactorHandler, selectReactorHandler } from '../reactors/ReactorDispatcher.js';
|
|
24
|
+
import { createHandlerInstanceValidator } from '../observation/createHandlerInstanceValidator.js';
|
|
24
25
|
import { getReactorMetadata } from '../reactors/reactor.js';
|
|
25
26
|
import { normalizeReactorSideEffects } from '../reactors/ReactorSideEffects.js';
|
|
26
27
|
import type { ReactorServices } from '../reactors/ReactorServices.js';
|
|
@@ -40,6 +41,7 @@ export class ReactorScenario {
|
|
|
40
41
|
private readonly _entries: ReturnType<typeof getReactorEventTypes>;
|
|
41
42
|
private readonly _eventTypes: readonly Constructor[];
|
|
42
43
|
private readonly _instance?: Record<string, Function>;
|
|
44
|
+
private readonly _validateInstance: (instance: object) => void;
|
|
43
45
|
private readonly _store: IEventStore;
|
|
44
46
|
private readonly _controller = new AbortController();
|
|
45
47
|
private readonly _effects: RecordedReactorSideEffect[] = [];
|
|
@@ -76,9 +78,12 @@ export class ReactorScenario {
|
|
|
76
78
|
eventTypeMigrations: eventTypeMigrations === undefined ? undefined : [...eventTypeMigrations]
|
|
77
79
|
} });
|
|
78
80
|
this._entries = getReactorEventTypes(_reactor, this._eventTypes);
|
|
81
|
+
this._validateInstance = createHandlerInstanceValidator(_reactor,
|
|
82
|
+
this._entries.flatMap(entry => [entry.methodName, entry.replayMethodName].filter((name): name is string => !!name)));
|
|
79
83
|
// Every unsupported event-sequence operation is latched at its source, whichever result or stream carries it.
|
|
80
84
|
observeUnsupportedOperations(this._events.eventSequence, error => { this.latch(error); });
|
|
81
85
|
this._instance = _options.artifactActivator ? undefined : new (_reactor as new () => Record<string, Function>)();
|
|
86
|
+
if (this._instance) this._validateInstance(this._instance);
|
|
82
87
|
this._store = _options.servicesEventStore ?? this.scenarioStore();
|
|
83
88
|
this.given = { forEventSource: id => ({ events: (...events) => this.deliver(id, events, true) }) };
|
|
84
89
|
this.when = { forEventSource: id => ({ events: (...events) => this.deliver(id, events, false) }) };
|
|
@@ -246,7 +251,10 @@ export class ReactorScenario {
|
|
|
246
251
|
artifactId: getReactorMetadata(this._reactor)!.id.value, eventStore: this._store,
|
|
247
252
|
readModels: services.readModels, eventSequenceId: 'event-log', partition: sourceId,
|
|
248
253
|
signal: services.signal, delivery: ArtifactDelivery.Events, eventContext: first.context },
|
|
249
|
-
this._options.artifactActivator,
|
|
254
|
+
this._options.artifactActivator, artifact => {
|
|
255
|
+
this._validateInstance(artifact.instance);
|
|
256
|
+
return process(artifact);
|
|
257
|
+
});
|
|
250
258
|
} else {
|
|
251
259
|
await process({ instance: this._instance ?? {} });
|
|
252
260
|
}
|
|
@@ -18,6 +18,7 @@ export class ReducerReadModelProcessor<TReadModel extends object> implements IRe
|
|
|
18
18
|
async process(events: readonly ScenarioEvent[]): Promise<Map<string, ReadModelState<TReadModel>>> {
|
|
19
19
|
const results = new Map<string, ReadModelState<TReadModel>>();
|
|
20
20
|
const reducer = new (this._reducerType as new () => Record<string, Function>)();
|
|
21
|
+
this._dispatcher.validateInstance(reducer);
|
|
21
22
|
for (const event of events) {
|
|
22
23
|
const handler = this._dispatcher.handlerFor(event.context.eventType.id.value);
|
|
23
24
|
if (!handler) continue;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
import { field } from '@cratis/fundamentals';
|
|
5
|
+
import { beforeEach, chai, describe, it } from 'vitest';
|
|
6
|
+
import { eventType, handles, onceOnly, reactor, type EventContext, type ReactorServices } from '../../index.js';
|
|
7
|
+
import { ReactorScenario } from '../index.js';
|
|
8
|
+
|
|
9
|
+
chai.should();
|
|
10
|
+
|
|
11
|
+
@eventType('scenario-explicit-author')
|
|
12
|
+
class AuthorRegistered {
|
|
13
|
+
@field(String) name: string;
|
|
14
|
+
constructor(name: string) { this.name = name; }
|
|
15
|
+
}
|
|
16
|
+
@eventType('scenario-explicit-notification')
|
|
17
|
+
class AuthorNotified {
|
|
18
|
+
@field(String) name: string;
|
|
19
|
+
constructor(name: string) { this.name = name; }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const calls: { name: string; source: string; method: string }[] = [];
|
|
23
|
+
@reactor('explicit-scenario-handler')
|
|
24
|
+
class AuthorReactor {
|
|
25
|
+
@onceOnly()
|
|
26
|
+
@handles(AuthorRegistered)
|
|
27
|
+
notify(event: AuthorRegistered, context: EventContext, services: ReactorServices) {
|
|
28
|
+
(services.readModels === services.eventStore.readModels).should.be.true;
|
|
29
|
+
calls.push({ name: event.name, source: context.eventSourceId, method: 'notify' });
|
|
30
|
+
return new AuthorNotified(event.name);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const options = { artifacts: { eventTypes: [AuthorRegistered, AuthorNotified] }, constraints: 'disabled' as const };
|
|
35
|
+
|
|
36
|
+
describe('when delivering a live event to an explicitly named onceOnly handler in ReactorScenario', () => {
|
|
37
|
+
let scenario: ReactorScenario;
|
|
38
|
+
beforeEach(async () => {
|
|
39
|
+
calls.length = 0;
|
|
40
|
+
scenario = new ReactorScenario(AuthorReactor, options);
|
|
41
|
+
await scenario.when.forEventSource('author-1').events(new AuthorRegistered('Ada'));
|
|
42
|
+
});
|
|
43
|
+
it('should invoke the method with the event and context', () => {
|
|
44
|
+
calls.should.deep.equal([{ name: 'Ada', source: 'author-1', method: 'notify' }]);
|
|
45
|
+
});
|
|
46
|
+
it('should process returned side effects', () => {
|
|
47
|
+
scenario.shouldHaveProduced(AuthorNotified, event => event.name === 'Ada');
|
|
48
|
+
});
|
|
49
|
+
it('should complete delivery', () => {
|
|
50
|
+
scenario.results[0].completed.should.be.true;
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('when ReactorScenario has an unregistered explicit handler', () => {
|
|
55
|
+
it('should reject it before constructing the reactor', () => {
|
|
56
|
+
let constructed = false;
|
|
57
|
+
@reactor('invalid-explicit-scenario')
|
|
58
|
+
class Invalid {
|
|
59
|
+
constructor() { constructed = true; }
|
|
60
|
+
@handles(AuthorRegistered) notify() {}
|
|
61
|
+
}
|
|
62
|
+
(() => new ReactorScenario(Invalid, { ...options, artifacts: { eventTypes: [AuthorNotified] } }))
|
|
63
|
+
.should.throw(/notify.*AuthorRegistered.*no registered event type/);
|
|
64
|
+
constructed.should.be.false;
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
@reactor('field-shadowing-scenario')
|
|
69
|
+
class FieldReactor extends AuthorReactor {
|
|
70
|
+
override notify = () => new AuthorNotified('hidden');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe('when an instance field hides an inherited ReactorScenario handler', () => {
|
|
74
|
+
it('should reject the field after constructing the reactor', () => {
|
|
75
|
+
(() => new ReactorScenario(FieldReactor, options)).should.throw(
|
|
76
|
+
"Override 'notify' on 'FieldReactor' hides @handles(AuthorRegistered) declared on 'AuthorReactor'; use a method with @handles instead of an instance field.");
|
|
77
|
+
});
|
|
78
|
+
it('should reject an activator-created field before delivery and dispose its lease', async () => {
|
|
79
|
+
let disposed = false;
|
|
80
|
+
const scenario = new ReactorScenario(FieldReactor, { ...options, artifactActivator: type => ({
|
|
81
|
+
instance: new type(), dispose: () => { disposed = true; }
|
|
82
|
+
}) });
|
|
83
|
+
const [result] = await Promise.allSettled([scenario.when.forEventSource('author').events(new AuthorRegistered('Ada'))]);
|
|
84
|
+
result.status.should.equal('rejected');
|
|
85
|
+
if (result.status === 'rejected') (result.reason as Error).message.should.match(/Override 'notify'.*use a method/);
|
|
86
|
+
scenario.produced.should.have.lengthOf(0);
|
|
87
|
+
disposed.should.be.true;
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('when ReactorScenario has duplicate explicit and conventional handlers', () => {
|
|
92
|
+
@reactor('duplicate-explicit-scenario')
|
|
93
|
+
class Invalid {
|
|
94
|
+
@handles(AuthorRegistered) notify() {}
|
|
95
|
+
authorRegistered() {}
|
|
96
|
+
}
|
|
97
|
+
it('should reject the ambiguous event delivery', () => {
|
|
98
|
+
(() => new ReactorScenario(Invalid, options)).should.throw(/multiple handlers.*scenario-explicit-author/);
|
|
99
|
+
});
|
|
100
|
+
});
|
package/testing/for_ReadModelScenario/when_evaluating_arithmetic/with_a_fluent_projection.spec.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
import { field } from '@cratis/fundamentals';
|
|
5
|
+
import { chai, describe, it } from 'vitest';
|
|
6
|
+
import { eventType } from '../../../events/eventTypeDecorator.js';
|
|
7
|
+
import type { IProjectionBuilderFor } from '../../../projections/declarative/IProjectionBuilderFor.js';
|
|
8
|
+
import { projection } from '../../../projections/declarative/projection.js';
|
|
9
|
+
import { ReadModelScenario } from '../../ReadModelScenario.js';
|
|
10
|
+
import { UnsupportedProjectionOperation } from '../../projections/UnsupportedProjectionOperation.js';
|
|
11
|
+
|
|
12
|
+
chai.should();
|
|
13
|
+
|
|
14
|
+
class Changed {
|
|
15
|
+
constructor(readonly amount: number) {}
|
|
16
|
+
}
|
|
17
|
+
field(Number)(Changed.prototype, 'amount');
|
|
18
|
+
eventType('fluent-arithmetic-changed')(Changed);
|
|
19
|
+
class Removed {}
|
|
20
|
+
eventType('fluent-arithmetic-removed')(Removed);
|
|
21
|
+
class Totals {
|
|
22
|
+
id = '';
|
|
23
|
+
added = 0;
|
|
24
|
+
subtracted = 0;
|
|
25
|
+
counted = 0;
|
|
26
|
+
incremented = 0;
|
|
27
|
+
decremented = 0;
|
|
28
|
+
}
|
|
29
|
+
field(String)(Totals.prototype, 'id');
|
|
30
|
+
for (const property of ['added', 'subtracted', 'counted', 'incremented', 'decremented']) field(Number)(Totals.prototype, property);
|
|
31
|
+
class TotalsProjection {
|
|
32
|
+
define(builder: IProjectionBuilderFor<Totals>): void {
|
|
33
|
+
builder.noAutoMap().withInitialValues(() => ({ id: '', added: 10, subtracted: 10, counted: 0, incremented: 0, decremented: 0 }))
|
|
34
|
+
.from(Changed, from => from.add(model => model.added).with(event => event.amount)
|
|
35
|
+
.subtract(model => model.subtracted).with(event => event.amount)
|
|
36
|
+
.count(model => model.counted).increment(model => model.incremented).decrement(model => model.decremented))
|
|
37
|
+
.removedWith(Removed);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
projection('fluent-arithmetic', Totals)(TotalsProjection);
|
|
41
|
+
const artifacts = { eventTypes: [Changed, Removed], projections: [TotalsProjection], reducers: [] };
|
|
42
|
+
|
|
43
|
+
describe('when evaluating arithmetic with a fluent projection', () => {
|
|
44
|
+
for (const operation of ['add', 'subtract'] as const) {
|
|
45
|
+
it(`should reject fluent ${operation} with a snake_case operand before any events are seeded`, () => {
|
|
46
|
+
class PriceChanged {
|
|
47
|
+
unit_price = 2;
|
|
48
|
+
}
|
|
49
|
+
field(Number)(PriceChanged.prototype, 'unit_price');
|
|
50
|
+
eventType(`fluent-arithmetic-snake-case-${operation}`)(PriceChanged);
|
|
51
|
+
class PriceTotals {
|
|
52
|
+
id = '';
|
|
53
|
+
total = 0;
|
|
54
|
+
}
|
|
55
|
+
field(String)(PriceTotals.prototype, 'id');
|
|
56
|
+
field(Number)(PriceTotals.prototype, 'total');
|
|
57
|
+
class PriceProjection {
|
|
58
|
+
define(builder: IProjectionBuilderFor<PriceTotals>): void {
|
|
59
|
+
builder.from(PriceChanged, from => from[operation](model => model.total).with(event => event.unit_price));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
projection(`fluent-arithmetic-snake-case-${operation}`, PriceTotals)(PriceProjection);
|
|
63
|
+
(() => new ReadModelScenario(PriceTotals, { eventTypes: [PriceChanged], projections: [PriceProjection], reducers: [] }))
|
|
64
|
+
.should.throw(UnsupportedProjectionOperation).with.property('message')
|
|
65
|
+
.that.includes('kernel only permits an underscore as the first character')
|
|
66
|
+
.and.includes('https://github.com/Cratis/Chronicle/issues/4491');
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
it('should apply all five property operations to initial values', async () => {
|
|
71
|
+
const scenario = new ReadModelScenario(Totals, artifacts);
|
|
72
|
+
scenario.given.forEventSource('A').events(new Changed(2.5), new Changed(-1));
|
|
73
|
+
const result = (await scenario.instance)!;
|
|
74
|
+
[result.added, result.subtracted, result.counted, result.incremented, result.decremented].should.deep.equal([11.5, 8.5, 2, 2, -2]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should discard the accumulator on removal and reapply initial values on recreation', async () => {
|
|
78
|
+
const scenario = new ReadModelScenario(Totals, artifacts);
|
|
79
|
+
scenario.given.forEventSource('A').events(new Changed(3), new Removed());
|
|
80
|
+
(await scenario.wasDeletedForEventSourceId('A')).should.be.true;
|
|
81
|
+
scenario.given.forEventSource('A').events(new Changed(4));
|
|
82
|
+
const result = (await scenario.instance)!;
|
|
83
|
+
[result.added, result.counted].should.deep.equal([14, 1]);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
import { field } from '@cratis/fundamentals';
|
|
5
|
+
import { chai, describe, it } from 'vitest';
|
|
6
|
+
import { eventType } from '../../../events/eventTypeDecorator.js';
|
|
7
|
+
import { addFrom } from '../../../projections/modelBound/addFrom.js';
|
|
8
|
+
import { subtractFrom } from '../../../projections/modelBound/subtractFrom.js';
|
|
9
|
+
import { count } from '../../../projections/modelBound/count.js';
|
|
10
|
+
import { increment } from '../../../projections/modelBound/increment.js';
|
|
11
|
+
import { decrement } from '../../../projections/modelBound/decrement.js';
|
|
12
|
+
import { setFrom } from '../../../projections/modelBound/setFrom.js';
|
|
13
|
+
import { ReadModelScenario } from '../../ReadModelScenario.js';
|
|
14
|
+
import { UnsupportedProjectionOperation } from '../../projections/UnsupportedProjectionOperation.js';
|
|
15
|
+
|
|
16
|
+
chai.should();
|
|
17
|
+
|
|
18
|
+
@eventType('legacy-arithmetic-added')
|
|
19
|
+
class Added {
|
|
20
|
+
@field(Number) amount: number;
|
|
21
|
+
@field(String) title = 'not mapped';
|
|
22
|
+
constructor(amount: number) { this.amount = amount; }
|
|
23
|
+
}
|
|
24
|
+
@eventType('legacy-arithmetic-subtracted')
|
|
25
|
+
class Subtracted {
|
|
26
|
+
@field(Number) amount: number;
|
|
27
|
+
constructor(amount: number) { this.amount = amount; }
|
|
28
|
+
}
|
|
29
|
+
@eventType('legacy-arithmetic-renamed')
|
|
30
|
+
class Renamed {
|
|
31
|
+
@field(Number) amount = 1;
|
|
32
|
+
@field(String) caption = 'explicit';
|
|
33
|
+
@field(String) title = 'automapped';
|
|
34
|
+
}
|
|
35
|
+
class Totals {
|
|
36
|
+
@field(String) id = '';
|
|
37
|
+
@field(Number) @addFrom(Added, 'amount') @subtractFrom(Subtracted, 'amount') @addFrom(Renamed, 'amount') total = 0;
|
|
38
|
+
@field(Number) @addFrom(Added) @subtractFrom(Subtracted) amount = 0;
|
|
39
|
+
@field(Number) @count(Added) counted = 0;
|
|
40
|
+
@field(Number) @increment(Added) incremented = 0;
|
|
41
|
+
@field(Number) @decrement(Added) decremented = 0;
|
|
42
|
+
@field(String) @setFrom(Renamed, 'caption') label = '';
|
|
43
|
+
@field(String) title = '';
|
|
44
|
+
}
|
|
45
|
+
const artifacts = { eventTypes: [Added, Subtracted, Renamed], readModels: [Totals], reducers: [], projections: [] };
|
|
46
|
+
|
|
47
|
+
// The same operations are captured in arithmetic.json, arithmetic-numeric-matrix.json and arithmetic-automap.json.
|
|
48
|
+
describe('when evaluating arithmetic with legacy model-bound decorators', () => {
|
|
49
|
+
for (const [operation, decorate] of [['add', addFrom], ['subtract', subtractFrom]] as const) {
|
|
50
|
+
it(`should reject @${operation}From with a snake_case operand before any events are seeded`, () => {
|
|
51
|
+
@eventType(`legacy-arithmetic-snake-case-${operation}`)
|
|
52
|
+
class PriceChanged {
|
|
53
|
+
@field(Number) unit_price = 2;
|
|
54
|
+
}
|
|
55
|
+
class PriceTotals {
|
|
56
|
+
@field(String) id = '';
|
|
57
|
+
@field(Number) @decorate(PriceChanged, 'unit_price') total = 0;
|
|
58
|
+
}
|
|
59
|
+
(() => new ReadModelScenario(PriceTotals, { eventTypes: [PriceChanged], readModels: [PriceTotals], reducers: [], projections: [] }))
|
|
60
|
+
.should.throw(UnsupportedProjectionOperation).with.property('message')
|
|
61
|
+
.that.includes('kernel only permits an underscore as the first character')
|
|
62
|
+
.and.includes('https://github.com/Cratis/Chronicle/issues/4491');
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
it('should accumulate all five operations and default event property names', async () => {
|
|
67
|
+
const scenario = new ReadModelScenario(Totals, artifacts);
|
|
68
|
+
scenario.given.forEventSource('A').events(new Added(2.5), new Added(3), new Subtracted(1));
|
|
69
|
+
const result = (await scenario.instance)!;
|
|
70
|
+
[result.total, result.amount, result.counted, result.incremented, result.decremented].should.deep.equal([4.5, 4.5, 2, 2, -2]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should replay additional seeds without counting the previous replay twice and keep sources independent', async () => {
|
|
74
|
+
const scenario = new ReadModelScenario(Totals, artifacts);
|
|
75
|
+
scenario.given.forEventSource('A').events(new Added(2));
|
|
76
|
+
(await scenario.instance)!.total.should.equal(2);
|
|
77
|
+
scenario.given.forEventSource('B').events(new Added(7));
|
|
78
|
+
scenario.given.forEventSource('A').events(new Added(3));
|
|
79
|
+
(await scenario.instanceForEventSourceId('A'))!.total.should.equal(5);
|
|
80
|
+
(await scenario.instanceForEventSourceId('B'))!.total.should.equal(7);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('should suppress aggregate-only AutoMap and restore it for mixed mappings', async () => {
|
|
84
|
+
const scenario = new ReadModelScenario(Totals, artifacts);
|
|
85
|
+
scenario.given.forEventSource('A').events(new Added(2));
|
|
86
|
+
((await scenario.instance)!.title === undefined).should.be.true;
|
|
87
|
+
scenario.given.forEventSource('A').events(new Renamed());
|
|
88
|
+
const result = (await scenario.instance)!;
|
|
89
|
+
[result.total, result.title, result.label].should.deep.equal([3, 'automapped', 'explicit']);
|
|
90
|
+
});
|
|
91
|
+
});
|