@reventlessdev/reventless-gwt 1.0.0-alpha.100
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 +916 -0
- package/LICENSE +202 -0
- package/bin/reventless-dev.mjs +21 -0
- package/package.json +54 -0
- package/rescript.json +36 -0
- package/src/.gwtignore +10 -0
- package/src/Automation_GWT.res +207 -0
- package/src/Automation_GWT.res.mjs +160 -0
- package/src/Behavior_GWT.res +569 -0
- package/src/Behavior_GWT.res.mjs +523 -0
- package/src/Bind.res +29 -0
- package/src/Bind.res.mjs +22 -0
- package/src/BuildClassifier.res +147 -0
- package/src/BuildClassifier.res.mjs +136 -0
- package/src/Cancellation.res +37 -0
- package/src/Cancellation.res.mjs +46 -0
- package/src/ChildProcess.res +102 -0
- package/src/ChildProcess.res.mjs +107 -0
- package/src/Cli.res +905 -0
- package/src/Cli.res.mjs +952 -0
- package/src/Collector.res +212 -0
- package/src/Collector.res.mjs +218 -0
- package/src/ComponentMeta.res +77 -0
- package/src/ComponentMeta.res.mjs +91 -0
- package/src/ComponentScan.res +103 -0
- package/src/ComponentScan.res.mjs +105 -0
- package/src/Delegate_GWT.res +303 -0
- package/src/Delegate_GWT.res.mjs +372 -0
- package/src/Diff.res +91 -0
- package/src/Diff.res.mjs +98 -0
- package/src/Discovery.res +105 -0
- package/src/Discovery.res.mjs +99 -0
- package/src/DomainDeadCode.res +98 -0
- package/src/DomainDeadCode.res.mjs +72 -0
- package/src/DomainGraph.res +0 -0
- package/src/DomainGraph.res.mjs +0 -0
- package/src/EventMapping_GWT.res +29 -0
- package/src/EventMapping_GWT.res.mjs +44 -0
- package/src/Filter.res +45 -0
- package/src/Filter.res.mjs +47 -0
- package/src/Flow_GWT.res +664 -0
- package/src/Flow_GWT.res.mjs +604 -0
- package/src/FormatterHuman.res +109 -0
- package/src/FormatterHuman.res.mjs +115 -0
- package/src/FormatterJson.res +264 -0
- package/src/FormatterJson.res.mjs +322 -0
- package/src/FormatterJunit.res +93 -0
- package/src/FormatterJunit.res.mjs +90 -0
- package/src/FormatterTap.res +100 -0
- package/src/FormatterTap.res.mjs +131 -0
- package/src/FormatterVsCode.res +352 -0
- package/src/FormatterVsCode.res.mjs +546 -0
- package/src/Hint.res +119 -0
- package/src/Hint.res.mjs +124 -0
- package/src/InboundTranslation_GWT.res +83 -0
- package/src/InboundTranslation_GWT.res.mjs +94 -0
- package/src/JestBind.res +70 -0
- package/src/JestBind.res.mjs +63 -0
- package/src/Loader.res +24 -0
- package/src/Loader.res.mjs +23 -0
- package/src/LocalHost.res +119 -0
- package/src/LocalHost.res.mjs +99 -0
- package/src/Mapping_GWT.res +474 -0
- package/src/Mapping_GWT.res.mjs +322 -0
- package/src/MismatchRender.res +72 -0
- package/src/MismatchRender.res.mjs +127 -0
- package/src/MultiSourceProjection_GWT.res +316 -0
- package/src/MultiSourceProjection_GWT.res.mjs +354 -0
- package/src/OutboundTranslation_GWT.res +238 -0
- package/src/OutboundTranslation_GWT.res.mjs +186 -0
- package/src/Outcome.res +97 -0
- package/src/Outcome.res.mjs +93 -0
- package/src/PackageScan.res +80 -0
- package/src/PackageScan.res.mjs +81 -0
- package/src/PlatformRunner.res +181 -0
- package/src/PlatformRunner.res.mjs +176 -0
- package/src/PlatformScan.res +142 -0
- package/src/PlatformScan.res.mjs +132 -0
- package/src/ProcessManager.res +135 -0
- package/src/ProcessManager.res.mjs +119 -0
- package/src/Projection_GWT.res +312 -0
- package/src/Projection_GWT.res.mjs +332 -0
- package/src/Query_GWT.res +379 -0
- package/src/Query_GWT.res.mjs +367 -0
- package/src/RenderRescript.res +125 -0
- package/src/RenderRescript.res.mjs +141 -0
- package/src/RunWorker.res +24 -0
- package/src/RunWorker.res.mjs +16 -0
- package/src/RunnerTypes.res +68 -0
- package/src/RunnerTypes.res.mjs +60 -0
- package/src/SideEffect_GWT.res +157 -0
- package/src/SideEffect_GWT.res.mjs +113 -0
- package/src/StateChange/ExternalAddCategorySlice.res +25 -0
- package/src/StateChange/ExternalAddCategorySlice.res.mjs +34 -0
- package/src/StateChange/ExternalAddCategorySlice_Behavior.res +19 -0
- package/src/StateChange/ExternalAddCategorySlice_Behavior.res.mjs +46 -0
- package/src/StateChange/WithFixtures.res +22 -0
- package/src/StateChange/WithFixtures.res.mjs +31 -0
- package/src/StateChange/WithFixtures_Behavior.res +17 -0
- package/src/StateChange/WithFixtures_Behavior.res.mjs +37 -0
- package/src/StateChange/WithManualOpen.res +23 -0
- package/src/StateChange/WithManualOpen.res.mjs +31 -0
- package/src/StateChange/WithManualOpen_Behavior.res +17 -0
- package/src/StateChange/WithManualOpen_Behavior.res.mjs +37 -0
- package/src/StubRuntime.res +54 -0
- package/src/StubRuntime.res.mjs +55 -0
- package/src/TestFixtures.res +16 -0
- package/src/TestFixtures.res.mjs +34 -0
- package/src/Watch.res +108 -0
- package/src/Watch.res.mjs +85 -0
- package/src/WatcherProbe.res +56 -0
- package/src/WatcherProbe.res.mjs +59 -0
- package/src/Worker.res +24 -0
- package/src/Worker.res.mjs +9 -0
- package/tests/AutomationGwtTest.res +71 -0
- package/tests/AutomationGwtTest.res.mjs +189 -0
- package/tests/BuildClassifierTest.res +133 -0
- package/tests/BuildClassifierTest.res.mjs +176 -0
- package/tests/CliRunEntryTest.res +57 -0
- package/tests/CliRunEntryTest.res.mjs +65 -0
- package/tests/CliWatchScopeTest.res +62 -0
- package/tests/CliWatchScopeTest.res.mjs +95 -0
- package/tests/ComponentMetaTest.res +67 -0
- package/tests/ComponentMetaTest.res.mjs +71 -0
- package/tests/DelegateGwtTest.res +487 -0
- package/tests/DelegateGwtTest.res.mjs +891 -0
- package/tests/DiscoveryTest.res +42 -0
- package/tests/DiscoveryTest.res.mjs +43 -0
- package/tests/DomainDeadCodeTest.res +119 -0
- package/tests/DomainDeadCodeTest.res.mjs +158 -0
- package/tests/DomainGraphTest.res +448 -0
- package/tests/DomainGraphTest.res.mjs +505 -0
- package/tests/FlowAggregateGwtTest.res +171 -0
- package/tests/FlowAggregateGwtTest.res.mjs +273 -0
- package/tests/FlowCrossPluginGwtTest.res +340 -0
- package/tests/FlowCrossPluginGwtTest.res.mjs +798 -0
- package/tests/FlowGwtTest.res +222 -0
- package/tests/FlowGwtTest.res.mjs +433 -0
- package/tests/FormatterGoldenTest.res +125 -0
- package/tests/FormatterGoldenTest.res.mjs +162 -0
- package/tests/FormatterVsCodeMessageTest.res +41 -0
- package/tests/FormatterVsCodeMessageTest.res.mjs +42 -0
- package/tests/InboundTranslationGwtTest.res +34 -0
- package/tests/InboundTranslationGwtTest.res.mjs +99 -0
- package/tests/LocalHostIntegration.res +70 -0
- package/tests/LocalHostIntegration.res.mjs +64 -0
- package/tests/LocalHostTest.res +71 -0
- package/tests/LocalHostTest.res.mjs +63 -0
- package/tests/MappingGwtTest.res +350 -0
- package/tests/MappingGwtTest.res.mjs +596 -0
- package/tests/OutboundTranslationGwtTest.res +59 -0
- package/tests/OutboundTranslationGwtTest.res.mjs +139 -0
- package/tests/PlatformRunnerTest.res +66 -0
- package/tests/PlatformRunnerTest.res.mjs +85 -0
- package/tests/PlatformScanTest.res +61 -0
- package/tests/PlatformScanTest.res.mjs +56 -0
- package/tests/QueryGwtTest.res +161 -0
- package/tests/QueryGwtTest.res.mjs +314 -0
- package/tests/QueryResolverGwtTest.res +71 -0
- package/tests/QueryResolverGwtTest.res.mjs +136 -0
- package/tests/RunnerUnitTest.res +219 -0
- package/tests/RunnerUnitTest.res.mjs +244 -0
- package/tests/SideEffect/FakeOrderNotification.res +23 -0
- package/tests/SideEffect/FakeOrderNotification.res.mjs +36 -0
- package/tests/SideEffect/FakeOrderNotificationGwtTest.res +35 -0
- package/tests/SideEffect/FakeOrderNotificationGwtTest.res.mjs +73 -0
- package/tests/SideEffect/MockEmail.res +18 -0
- package/tests/SideEffect/MockEmail.res.mjs +47 -0
- package/tests/StateChange/ExternalAddCategorySlice_GWT.res +27 -0
- package/tests/StateChange/ExternalAddCategorySlice_GWT.res.mjs +70 -0
- package/tests/StateChange/WithFixtures_Fixtures.res +9 -0
- package/tests/StateChange/WithFixtures_Fixtures.res.mjs +20 -0
- package/tests/StateChange/WithFixtures_GWT.res +23 -0
- package/tests/StateChange/WithFixtures_GWT.res.mjs +56 -0
- package/tests/StateChange/WithManualOpen_Fixtures.res +7 -0
- package/tests/StateChange/WithManualOpen_Fixtures.res.mjs +20 -0
- package/tests/StateChange/WithManualOpen_GWT.res +17 -0
- package/tests/StateChange/WithManualOpen_GWT.res.mjs +56 -0
- package/tests/StateChangeSliceGwtTest.res +160 -0
- package/tests/StateChangeSliceGwtTest.res.mjs +280 -0
- package/tests/StateViewSliceGwtTest.res +64 -0
- package/tests/StateViewSliceGwtTest.res.mjs +161 -0
- package/tests/WatchDebounceTest.res +106 -0
- package/tests/WatchDebounceTest.res.mjs +128 -0
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
// Unit tests for DomainGraph.build — Event-Modeling node/edge assembly over
|
|
2
|
+
// synthetic pluginStructures. Pure (no reventless-local import), so it runs under
|
|
3
|
+
// Jest. The real end-to-end shape over the example plugins is exercised by the
|
|
4
|
+
// LocalHost integration; here hand-built fixtures pin the edge wiring.
|
|
5
|
+
|
|
6
|
+
open JestGlobals
|
|
7
|
+
open ReventlessVscodeProtocol.Protocol
|
|
8
|
+
|
|
9
|
+
let command = (~name, ~mutationField, ~apiExposed=None): Reventless.Plugin.commandDef => {
|
|
10
|
+
name,
|
|
11
|
+
schema: "",
|
|
12
|
+
level: Instance,
|
|
13
|
+
aggregateIdField: None,
|
|
14
|
+
mutationField,
|
|
15
|
+
references: [],
|
|
16
|
+
allowedStates: None,
|
|
17
|
+
apiExposed,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let evt = (~name): Reventless.Plugin.eventDef => {name, schema: "", references: []}
|
|
21
|
+
|
|
22
|
+
let writable = (~name, ~commands=[], ~produces=[], ~consumes=[], ~linkedViews=[], ~events=[], ~chapter=None): Reventless.Plugin.writableDef => {
|
|
23
|
+
name,
|
|
24
|
+
commands,
|
|
25
|
+
producedEventTypes: produces,
|
|
26
|
+
consumedEventTypes: consumes,
|
|
27
|
+
linkedViews,
|
|
28
|
+
consistencyRead: None,
|
|
29
|
+
events,
|
|
30
|
+
chapter,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let queryable = (~name, ~consumes=[], ~visibility=None, ~chapter=None): Reventless.Plugin.queryableDef => {
|
|
34
|
+
name,
|
|
35
|
+
queryField: "",
|
|
36
|
+
schema: "",
|
|
37
|
+
consumedEventTypes: consumes,
|
|
38
|
+
linkedWriteSide: [],
|
|
39
|
+
labelField: "id",
|
|
40
|
+
searchableFields: [],
|
|
41
|
+
statusField: None,
|
|
42
|
+
visibility,
|
|
43
|
+
chapter,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let automation = (~name, ~consumes=[], ~produces=[], ~target="", ~chapter=None): Reventless.Plugin.automationSliceDef => {
|
|
47
|
+
name,
|
|
48
|
+
consumedEventTypes: consumes,
|
|
49
|
+
producedCommandTypes: produces,
|
|
50
|
+
targetName: target,
|
|
51
|
+
chapter,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let inbound = (~name, ~produces=[], ~target="", ~ext=None, ~chapter=None): Reventless.Plugin.inboundTranslationSliceDef => {
|
|
55
|
+
name,
|
|
56
|
+
commandTypes: produces,
|
|
57
|
+
targetName: target,
|
|
58
|
+
externalSystem: ext,
|
|
59
|
+
chapter,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let outbound = (~name, ~consumes=[], ~produces=[], ~target=None, ~ext=None, ~chapter=None): Reventless.Plugin.outboundTranslationSliceDef => {
|
|
63
|
+
name,
|
|
64
|
+
consumedEventTypes: consumes,
|
|
65
|
+
inboundCommandTypes: produces,
|
|
66
|
+
targetName: target,
|
|
67
|
+
externalSystem: ext,
|
|
68
|
+
chapter,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let structure = (
|
|
72
|
+
~aggregates=[],
|
|
73
|
+
~readModels=[],
|
|
74
|
+
~stateViewSlices=[],
|
|
75
|
+
~stateChangeSlices=[],
|
|
76
|
+
~automationSlices=[],
|
|
77
|
+
~outboundTranslationSlices=[],
|
|
78
|
+
~inboundTranslationSlices=[],
|
|
79
|
+
~extensionPoints=[],
|
|
80
|
+
): Reventless.Plugin.pluginStructure => {
|
|
81
|
+
readModels,
|
|
82
|
+
stateViewSlices,
|
|
83
|
+
stateChangeSlices,
|
|
84
|
+
aggregates,
|
|
85
|
+
automationSlices,
|
|
86
|
+
outboundTranslationSlices,
|
|
87
|
+
inboundTranslationSlices,
|
|
88
|
+
extensions: [],
|
|
89
|
+
extensionPoints: Some(extensionPoints),
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let hasEdge = (g: DomainGraph.graph, from, to, kind) =>
|
|
93
|
+
g.edges->Array.some(e => e.from == from && e.to_ == to && e.kind == kind)
|
|
94
|
+
let edgeLabel = (g: DomainGraph.graph, from, to, kind) =>
|
|
95
|
+
g.edges->Array.find(e => e.from == from && e.to_ == to && e.kind == kind)->Option.flatMap(e => e.label)
|
|
96
|
+
let nodeKind = (g: DomainGraph.graph, id) =>
|
|
97
|
+
g.nodes->Array.find(n => n.id == id)->Option.map(n => n.kind)
|
|
98
|
+
|
|
99
|
+
describe("DomainGraph.build", () => {
|
|
100
|
+
testPromise("Command → Aggregate → Event → ReadModel (DCB consumedEventTypes)", async () => {
|
|
101
|
+
let shop = structure(
|
|
102
|
+
~aggregates=[
|
|
103
|
+
writable(
|
|
104
|
+
~name="Order",
|
|
105
|
+
~commands=[command(~name="Place", ~mutationField="Shop_Order_Place")],
|
|
106
|
+
~produces=["Shop.Placed"],
|
|
107
|
+
),
|
|
108
|
+
],
|
|
109
|
+
~stateViewSlices=[queryable(~name="OrderView", ~consumes=["Shop.Placed"])],
|
|
110
|
+
)
|
|
111
|
+
let g = DomainGraph.build(~structures=[("Shop", shop)])
|
|
112
|
+
expect(nodeKind(g, "Shop_Order_Place"))->toEqual(Some(Command))
|
|
113
|
+
expect(nodeKind(g, "Shop:Order"))->toEqual(Some(Aggregate))
|
|
114
|
+
expect(nodeKind(g, "Shop.Placed"))->toEqual(Some(Event))
|
|
115
|
+
expect(nodeKind(g, "Shop:OrderView"))->toEqual(Some(StateViewSlice))
|
|
116
|
+
expect(hasEdge(g, "Shop_Order_Place", "Shop:Order", Handles))->toBe(true)
|
|
117
|
+
expect(hasEdge(g, "Shop:Order", "Shop.Placed", Emits))->toBe(true)
|
|
118
|
+
expect(hasEdge(g, "Shop.Placed", "Shop:OrderView", Projects))->toBe(true)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
testPromise("payload-less events (in `events`, not producedEventTypes) still get an emitted node", async () => {
|
|
122
|
+
// Mirrors an aggregate whose `| Archived` variant is payload-less: DCB drops it from
|
|
123
|
+
// producedEventTypes, but the full `events` list carries it so the graph draws the
|
|
124
|
+
// emitted (orphan) event node. The payloaded `Added` appears in both lists and dedups.
|
|
125
|
+
let shop = structure(
|
|
126
|
+
~aggregates=[
|
|
127
|
+
writable(
|
|
128
|
+
~name="Category",
|
|
129
|
+
~produces=["Catalog.Added"],
|
|
130
|
+
~events=[evt(~name="Added"), evt(~name="Archived")],
|
|
131
|
+
),
|
|
132
|
+
],
|
|
133
|
+
)
|
|
134
|
+
let g = DomainGraph.build(~structures=[("Catalog", shop)])
|
|
135
|
+
expect(nodeKind(g, "Catalog.Added"))->toEqual(Some(Event))
|
|
136
|
+
expect(nodeKind(g, "Catalog.Archived"))->toEqual(Some(Event))
|
|
137
|
+
expect(hasEdge(g, "Catalog:Category", "Catalog.Added", Emits))->toBe(true)
|
|
138
|
+
expect(hasEdge(g, "Catalog:Category", "Catalog.Archived", Emits))->toBe(true)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
testPromise("a payload-less event also projects into the aggregate's linked view", async () => {
|
|
142
|
+
// Classic aggregate → linked view: the projection drew only producedEventTypes, which
|
|
143
|
+
// drops payload-less events — so a bare `| Archived` emitted but never reached the view.
|
|
144
|
+
// It must now project too (matching the emit), just like the payloaded `Added`.
|
|
145
|
+
let shop = structure(
|
|
146
|
+
~aggregates=[
|
|
147
|
+
writable(
|
|
148
|
+
~name="Category",
|
|
149
|
+
~produces=["Catalog.Added"],
|
|
150
|
+
~events=[evt(~name="Added"), evt(~name="Archived")],
|
|
151
|
+
~linkedViews=["Categories"],
|
|
152
|
+
),
|
|
153
|
+
],
|
|
154
|
+
)
|
|
155
|
+
let g = DomainGraph.build(~structures=[("Catalog", shop)])
|
|
156
|
+
expect(hasEdge(g, "Catalog.Added", "Catalog:Categories", Projects))->toBe(true)
|
|
157
|
+
expect(hasEdge(g, "Catalog.Archived", "Catalog:Categories", Projects))->toBe(true)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
testPromise("an Internal StateViewSlice still gets a node + projects edge (dev graph shows it)", async () => {
|
|
161
|
+
// Internal components are hidden from the deployed AutoUI but carried in pluginStructure
|
|
162
|
+
// so the developer-facing domain graph can render them — see Plugin_Structure.res /
|
|
163
|
+
// Visibility.res. Mirrors the AvailableProducts case (a consume-only Internal slice).
|
|
164
|
+
let shop = structure(
|
|
165
|
+
~aggregates=[writable(~name="Order", ~produces=["Shop.Placed"])],
|
|
166
|
+
~stateViewSlices=[
|
|
167
|
+
queryable(~name="AvailableView", ~consumes=["Shop.Placed"], ~visibility=Some("Internal")),
|
|
168
|
+
],
|
|
169
|
+
)
|
|
170
|
+
let g = DomainGraph.build(~structures=[("Shop", shop)])
|
|
171
|
+
expect(nodeKind(g, "Shop:AvailableView"))->toEqual(Some(StateViewSlice))
|
|
172
|
+
expect(hasEdge(g, "Shop.Placed", "Shop:AvailableView", Projects))->toBe(true)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
testPromise("classic linkedViews draws each produced event into the linked read model", async () => {
|
|
176
|
+
let shop = structure(
|
|
177
|
+
~aggregates=[writable(~name="Order", ~produces=["Shop.Placed", "Shop.Shipped"], ~linkedViews=["Orders"])],
|
|
178
|
+
~readModels=[queryable(~name="Orders")],
|
|
179
|
+
)
|
|
180
|
+
let g = DomainGraph.build(~structures=[("Shop", shop)])
|
|
181
|
+
expect(hasEdge(g, "Shop.Placed", "Shop:Orders", Projects))->toBe(true)
|
|
182
|
+
expect(hasEdge(g, "Shop.Shipped", "Shop:Orders", Projects))->toBe(true)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
testPromise("automation slices are triggered by their consumed events", async () => {
|
|
186
|
+
let shop = structure(
|
|
187
|
+
~aggregates=[writable(~name="Order", ~produces=["Shop.Placed"])],
|
|
188
|
+
~automationSlices=[automation(~name="Notify", ~consumes=["Shop.Placed"])],
|
|
189
|
+
)
|
|
190
|
+
let g = DomainGraph.build(~structures=[("Shop", shop)])
|
|
191
|
+
expect(nodeKind(g, "Shop:Notify"))->toEqual(Some(AutomationSlice))
|
|
192
|
+
expect(hasEdge(g, "Shop.Placed", "Shop:Notify", Triggers))->toBe(true)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
testPromise("an automation routes to the specific command it raises on its target", async () => {
|
|
196
|
+
// AutoShipOrder reacts to OrderPlaced and raises a ShipOrder command on the Ship
|
|
197
|
+
// write-side. The produce side is drawn slice → command "delegatesTo" (unified with
|
|
198
|
+
// Command → write-side "handles"); only the named command is linked, not Place too.
|
|
199
|
+
let shop = structure(
|
|
200
|
+
~stateChangeSlices=[
|
|
201
|
+
writable(
|
|
202
|
+
~name="Ship",
|
|
203
|
+
~commands=[
|
|
204
|
+
command(~name="ShipOrder", ~mutationField="Shop_Ship_ShipOrder"),
|
|
205
|
+
command(~name="Place", ~mutationField="Shop_Ship_Place"),
|
|
206
|
+
],
|
|
207
|
+
~produces=["Shop.Shipped"],
|
|
208
|
+
),
|
|
209
|
+
],
|
|
210
|
+
~automationSlices=[
|
|
211
|
+
automation(~name="AutoShip", ~consumes=["Shop.Placed"], ~produces=["AutoShip.ShipOrder"], ~target="Ship"),
|
|
212
|
+
],
|
|
213
|
+
)
|
|
214
|
+
let g = DomainGraph.build(~structures=[("Shop", shop)])
|
|
215
|
+
expect(hasEdge(g, "Shop:AutoShip", "Shop_Ship_ShipOrder", DelegatesTo))->toBe(true)
|
|
216
|
+
// Matched by name — the target's other command is not linked.
|
|
217
|
+
expect(hasEdge(g, "Shop:AutoShip", "Shop_Ship_Place", DelegatesTo))->toBe(false)
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
testPromise("an inbound translation routes to the command it raises on its target", async () => {
|
|
221
|
+
// ImportProduct (external input → command) was a sink before; it now links to the
|
|
222
|
+
// Add command on its target write-side.
|
|
223
|
+
let shop = structure(
|
|
224
|
+
~stateChangeSlices=[
|
|
225
|
+
writable(~name="Product", ~commands=[command(~name="Add", ~mutationField="Shop_Product_Add")]),
|
|
226
|
+
],
|
|
227
|
+
~inboundTranslationSlices=[
|
|
228
|
+
inbound(~name="ImportProduct", ~produces=["ImportProduct.Add"], ~target="Product"),
|
|
229
|
+
],
|
|
230
|
+
)
|
|
231
|
+
let g = DomainGraph.build(~structures=[("Shop", shop)])
|
|
232
|
+
expect(nodeKind(g, "Shop:ImportProduct"))->toEqual(Some(InboundTranslationSlice))
|
|
233
|
+
expect(hasEdge(g, "Shop:ImportProduct", "Shop_Product_Add", DelegatesTo))->toBe(true)
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
testPromise("an outbound translation with a target routes to the command it raises", async () => {
|
|
237
|
+
let shop = structure(
|
|
238
|
+
~stateChangeSlices=[
|
|
239
|
+
writable(~name="Product", ~commands=[command(~name="Sync", ~mutationField="Shop_Product_Sync")]),
|
|
240
|
+
],
|
|
241
|
+
~outboundTranslationSlices=[
|
|
242
|
+
outbound(~name="ExportProduct", ~consumes=["Shop.Added"], ~produces=["ExportProduct.Sync"], ~target=Some("Product")),
|
|
243
|
+
],
|
|
244
|
+
)
|
|
245
|
+
let g = DomainGraph.build(~structures=[("Shop", shop)])
|
|
246
|
+
expect(hasEdge(g, "Shop:ExportProduct", "Shop_Product_Sync", DelegatesTo))->toBe(true)
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
testPromise("an inbound translation naming an external system draws a box feeding it", async () => {
|
|
250
|
+
let shop = structure(
|
|
251
|
+
~inboundTranslationSlices=[
|
|
252
|
+
inbound(~name="ImportProduct", ~produces=["ImportProduct.Add"], ~target="Product", ~ext=Some("SupplierFeed")),
|
|
253
|
+
],
|
|
254
|
+
)
|
|
255
|
+
let g = DomainGraph.build(~structures=[("Shop", shop)])
|
|
256
|
+
// External box: drawn OUTSIDE the plugin (plugin: "") with a stable "ext:<name>" id.
|
|
257
|
+
expect(nodeKind(g, "ext:SupplierFeed"))->toEqual(Some(ExternalSystem))
|
|
258
|
+
expect(g.nodes->Array.find(n => n.id == "ext:SupplierFeed")->Option.map(n => n.plugin))->toEqual(Some(""))
|
|
259
|
+
// Inbound direction: external → slice (unlabelled — the arrowhead carries the meaning).
|
|
260
|
+
expect(hasEdge(g, "ext:SupplierFeed", "Shop:ImportProduct", TranslatesIn))->toBe(true)
|
|
261
|
+
expect(edgeLabel(g, "ext:SupplierFeed", "Shop:ImportProduct", TranslatesIn))->toEqual(None)
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
testPromise("an outbound translation naming an external system feeds the box", async () => {
|
|
265
|
+
let shop = structure(
|
|
266
|
+
~outboundTranslationSlices=[
|
|
267
|
+
outbound(~name="SendEmail", ~consumes=["Shop.Placed"], ~target=None, ~ext=Some("EmailService")),
|
|
268
|
+
],
|
|
269
|
+
)
|
|
270
|
+
let g = DomainGraph.build(~structures=[("Shop", shop)])
|
|
271
|
+
expect(nodeKind(g, "ext:EmailService"))->toEqual(Some(ExternalSystem))
|
|
272
|
+
// Outbound direction: slice → external (unlabelled — the arrowhead carries the meaning).
|
|
273
|
+
expect(hasEdge(g, "Shop:SendEmail", "ext:EmailService", TranslatesOut))->toBe(true)
|
|
274
|
+
expect(edgeLabel(g, "Shop:SendEmail", "ext:EmailService", TranslatesOut))->toEqual(None)
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
testPromise("translation slices with no external system draw no box (opt-in)", async () => {
|
|
278
|
+
let shop = structure(
|
|
279
|
+
~inboundTranslationSlices=[inbound(~name="ImportProduct", ~target="Product")],
|
|
280
|
+
)
|
|
281
|
+
let g = DomainGraph.build(~structures=[("Shop", shop)])
|
|
282
|
+
expect(g.nodes->Array.some(n => n.kind == ExternalSystem))->toBe(false)
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
testPromise("two slices naming the same external system share one deduped box", async () => {
|
|
286
|
+
let a = structure(
|
|
287
|
+
~outboundTranslationSlices=[
|
|
288
|
+
outbound(~name="SendEmailA", ~consumes=["A.X"], ~target=None, ~ext=Some("EmailService")),
|
|
289
|
+
],
|
|
290
|
+
)
|
|
291
|
+
let b = structure(
|
|
292
|
+
~inboundTranslationSlices=[
|
|
293
|
+
inbound(~name="FromEmail", ~produces=["FromEmail.Record"], ~target="Inbox", ~ext=Some("EmailService")),
|
|
294
|
+
],
|
|
295
|
+
)
|
|
296
|
+
let g = DomainGraph.build(~structures=[("PluginA", a), ("PluginB", b)])
|
|
297
|
+
// One shared box (per-platform dedup by name) with both an out- and an in-edge.
|
|
298
|
+
expect(g.nodes->Array.filter(n => n.id == "ext:EmailService")->Array.length)->toBe(1)
|
|
299
|
+
expect(hasEdge(g, "PluginA:SendEmailA", "ext:EmailService", TranslatesOut))->toBe(true)
|
|
300
|
+
expect(hasEdge(g, "ext:EmailService", "PluginB:FromEmail", TranslatesIn))->toBe(true)
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
testPromise("an event produced by two write-sides is one node with two emit edges", async () => {
|
|
304
|
+
let shop = structure(
|
|
305
|
+
~aggregates=[
|
|
306
|
+
writable(~name="A", ~produces=["Shop.Touched"]),
|
|
307
|
+
writable(~name="B", ~produces=["Shop.Touched"]),
|
|
308
|
+
],
|
|
309
|
+
)
|
|
310
|
+
let g = DomainGraph.build(~structures=[("Shop", shop)])
|
|
311
|
+
expect(g.nodes->Array.filter(n => n.id == "Shop.Touched")->Array.length)->toBe(1)
|
|
312
|
+
expect(hasEdge(g, "Shop:A", "Shop.Touched", Emits))->toBe(true)
|
|
313
|
+
expect(hasEdge(g, "Shop:B", "Shop.Touched", Emits))->toBe(true)
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
testPromise("an extension renders the cross-plugin event flow EP→event→Extension→delegate", async () => {
|
|
317
|
+
// Catalog's ProductDemand handles the "Ordering.Orders" extension point's events.
|
|
318
|
+
let extension: Reventless.Plugin.extensionDef = {
|
|
319
|
+
name: "Ordering.Orders",
|
|
320
|
+
delegateNames: ["ProductDemand"],
|
|
321
|
+
eventTypes: ["Ordering.Orders.ItemOrdered"],
|
|
322
|
+
commandTypes: [],
|
|
323
|
+
}
|
|
324
|
+
let catalog = {
|
|
325
|
+
...structure(~aggregates=[writable(~name="ProductDemand", ~produces=["Catalog.Recorded"])]),
|
|
326
|
+
extensions: [extension],
|
|
327
|
+
}
|
|
328
|
+
let g = DomainGraph.build(~structures=[("Catalog", catalog)])
|
|
329
|
+
// EP (owned by Ordering) publishes its event; the Catalog extension consumes it…
|
|
330
|
+
expect(nodeKind(g, "Ordering:ep:Ordering.Orders"))->toEqual(Some(ExtensionPoint))
|
|
331
|
+
expect(hasEdge(g, "Ordering:ep:Ordering.Orders", "Ordering.Orders.ItemOrdered", Publishes))->toBe(true)
|
|
332
|
+
expect(hasEdge(g, "Ordering.Orders.ItemOrdered", "Catalog:ext:Ordering.Orders", Consumes))->toBe(true)
|
|
333
|
+
// …and delegates to ProductDemand.
|
|
334
|
+
expect(hasEdge(g, "Catalog:ext:Ordering.Orders", "Catalog:ProductDemand", DelegatesTo))->toBe(true)
|
|
335
|
+
// The protocol event is top-level (empty plugin) so it sits between the boxes…
|
|
336
|
+
let protoEvent =
|
|
337
|
+
g.nodes->Array.find(n => n.id == "Ordering.Orders.ItemOrdered")->Option.getOrThrow
|
|
338
|
+
expect(protoEvent.plugin)->toBe("")
|
|
339
|
+
// …and the extension is named by its EP + consuming plugin.
|
|
340
|
+
let ext = g.nodes->Array.find(n => n.id == "Catalog:ext:Ordering.Orders")->Option.getOrThrow
|
|
341
|
+
expect(ext.label)->toBe("Ordering.Orders.Catalog")
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
testPromise("an extension wires to the command(s) it creates, not straight to the delegate", async () => {
|
|
345
|
+
// The extension issues the delegate's RecordDemand command (its own commandTypes are
|
|
346
|
+
// empty — the commands belong to the delegate), so the arrow lands on the command
|
|
347
|
+
// node, which the ProductDemand slice handles — not straight on the slice.
|
|
348
|
+
let extension: Reventless.Plugin.extensionDef = {
|
|
349
|
+
name: "Ordering.Orders",
|
|
350
|
+
delegateNames: ["ProductDemand"],
|
|
351
|
+
eventTypes: ["Ordering.Orders.ItemOrdered"],
|
|
352
|
+
commandTypes: [],
|
|
353
|
+
}
|
|
354
|
+
let catalog = {
|
|
355
|
+
...structure(
|
|
356
|
+
~stateChangeSlices=[
|
|
357
|
+
writable(
|
|
358
|
+
~name="ProductDemand",
|
|
359
|
+
~commands=[command(~name="RecordDemand", ~mutationField="Catalog_ProductDemand_RecordDemand")],
|
|
360
|
+
~produces=["Catalog.Recorded"],
|
|
361
|
+
),
|
|
362
|
+
],
|
|
363
|
+
),
|
|
364
|
+
extensions: [extension],
|
|
365
|
+
}
|
|
366
|
+
let g = DomainGraph.build(~structures=[("Catalog", catalog)])
|
|
367
|
+
// Extension → the command it creates …
|
|
368
|
+
expect(
|
|
369
|
+
hasEdge(g, "Catalog:ext:Ordering.Orders", "Catalog_ProductDemand_RecordDemand", DelegatesTo),
|
|
370
|
+
)->toBe(true)
|
|
371
|
+
// … the command is handled by the slice (so the full path is Extension → Command → slice) …
|
|
372
|
+
expect(hasEdge(g, "Catalog_ProductDemand_RecordDemand", "Catalog:ProductDemand", Handles))->toBe(true)
|
|
373
|
+
// … and the extension no longer points straight at the slice.
|
|
374
|
+
expect(hasEdge(g, "Catalog:ext:Ordering.Orders", "Catalog:ProductDemand", DelegatesTo))->toBe(false)
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
testPromise("an extension with no commands falls back to delegating to the write-side", async () => {
|
|
378
|
+
let extension: Reventless.Plugin.extensionDef = {
|
|
379
|
+
name: "Ordering.Orders",
|
|
380
|
+
delegateNames: ["ProductDemand"],
|
|
381
|
+
eventTypes: ["Ordering.Orders.ItemOrdered"],
|
|
382
|
+
commandTypes: [],
|
|
383
|
+
}
|
|
384
|
+
let catalog = {
|
|
385
|
+
...structure(~aggregates=[writable(~name="ProductDemand", ~produces=["Catalog.Recorded"])]),
|
|
386
|
+
extensions: [extension],
|
|
387
|
+
}
|
|
388
|
+
let g = DomainGraph.build(~structures=[("Catalog", catalog)])
|
|
389
|
+
expect(hasEdge(g, "Catalog:ext:Ordering.Orders", "Catalog:ProductDemand", DelegatesTo))->toBe(true)
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
testPromise("an owned extension point is fed by the producers of its source events", async () => {
|
|
393
|
+
// Catalog owns the Catalog.Products EP; its source events are the internal
|
|
394
|
+
// Catalog events produced by AddProduct, so the producing write-side feeds
|
|
395
|
+
// the EP through those events.
|
|
396
|
+
let ep: Reventless.Plugin.extensionPointDef = {
|
|
397
|
+
name: "Catalog.Products",
|
|
398
|
+
delegateNames: ["CatalogDcbEventLog"],
|
|
399
|
+
sourceEventTypes: ["Catalog.ProductAdded"],
|
|
400
|
+
// `command = unit`: notification-only, no inbound command protocol.
|
|
401
|
+
commandTypes: Some([]),
|
|
402
|
+
}
|
|
403
|
+
let catalog = structure(
|
|
404
|
+
~stateChangeSlices=[
|
|
405
|
+
writable(
|
|
406
|
+
~name="AddProduct",
|
|
407
|
+
~commands=[command(~name="AddProduct", ~mutationField="Catalog_AddProduct")],
|
|
408
|
+
~produces=["Catalog.ProductAdded"],
|
|
409
|
+
),
|
|
410
|
+
],
|
|
411
|
+
~extensionPoints=[ep],
|
|
412
|
+
)
|
|
413
|
+
let g = DomainGraph.build(~structures=[("Catalog", catalog)])
|
|
414
|
+
expect(nodeKind(g, "Catalog:ep:Catalog.Products"))->toEqual(Some(ExtensionPoint))
|
|
415
|
+
// The producing write-side emits the source event…
|
|
416
|
+
expect(hasEdge(g, "Catalog:AddProduct", "Catalog.ProductAdded", Emits))->toBe(true)
|
|
417
|
+
// …which feeds the extension point — so EP focus reaches the producer.
|
|
418
|
+
expect(hasEdge(g, "Catalog.ProductAdded", "Catalog:ep:Catalog.Products", Feeds))->toBe(true)
|
|
419
|
+
// …but the EP has NO inbound command protocol (command = unit), so it routes
|
|
420
|
+
// nothing: no backwards EP → Command edge mislabelling the producer's own command.
|
|
421
|
+
expect(hasEdge(g, "Catalog:ep:Catalog.Products", "Catalog_AddProduct", RoutesTo))->toBe(false)
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
testPromise("an extension point with an inbound command protocol routes to its delegate's commands", async () => {
|
|
425
|
+
// The same shape, but the EP declares a non-unit `command` type — so it DOES
|
|
426
|
+
// accept commands inward and routes them to the write-side that produces its
|
|
427
|
+
// source events: EP → Command → write-side ("handles").
|
|
428
|
+
let ep: Reventless.Plugin.extensionPointDef = {
|
|
429
|
+
name: "Catalog.Products",
|
|
430
|
+
delegateNames: ["AddProduct"],
|
|
431
|
+
sourceEventTypes: ["Catalog.ProductAdded"],
|
|
432
|
+
commandTypes: Some(["Catalog.Products.RequestAddProduct"]),
|
|
433
|
+
}
|
|
434
|
+
let catalog = structure(
|
|
435
|
+
~stateChangeSlices=[
|
|
436
|
+
writable(
|
|
437
|
+
~name="AddProduct",
|
|
438
|
+
~commands=[command(~name="AddProduct", ~mutationField="Catalog_AddProduct")],
|
|
439
|
+
~produces=["Catalog.ProductAdded"],
|
|
440
|
+
),
|
|
441
|
+
],
|
|
442
|
+
~extensionPoints=[ep],
|
|
443
|
+
)
|
|
444
|
+
let g = DomainGraph.build(~structures=[("Catalog", catalog)])
|
|
445
|
+
expect(hasEdge(g, "Catalog:ep:Catalog.Products", "Catalog_AddProduct", RoutesTo))->toBe(true)
|
|
446
|
+
expect(hasEdge(g, "Catalog_AddProduct", "Catalog:AddProduct", Handles))->toBe(true)
|
|
447
|
+
})
|
|
448
|
+
})
|