data-primals-engine 1.2.1 → 1.2.3
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/README.md +69 -9
- package/client/src/DataEditor.jsx +1 -202
- package/client/src/DataLayout.jsx +4 -21
- package/client/src/DataTable.jsx +4 -2
- package/client/src/RestoreConfirmationModal.jsx +0 -137
- package/client/src/constants.js +1 -1
- package/client/src/hooks/useTutorials.jsx +3 -3
- package/package.json +2 -2
- package/src/constants.js +2 -2
- package/src/data.js +4 -4
- package/src/defaultModels.js +1 -14
- package/src/email.js +8 -6
- package/src/engine.js +12 -8
- package/src/events.js +26 -4
- package/src/filter.js +221 -0
- package/src/modules/bucket.js +115 -5
- package/src/modules/data.js +174 -101
- package/src/modules/file.js +98 -57
- package/src/modules/test +147 -0
- package/src/modules/workflow.js +266 -100
- package/src/packs.js +245 -7
- package/test/data.backup.integration.test.js +5 -2
- package/test/data.integration.test.js +10 -4
- package/test/events.test.js +202 -0
- package/test/file.test.js +193 -0
- package/test/import_export.integration.test.js +23 -19
- package/test/model.integration.test.js +20 -19
- package/test/user.test.js +30 -23
- package/test/vm.test.js +51 -0
- package/test/workflow.integration.test.js +6 -1
- package/test/workflow.robustness.test.js +6 -1
package/src/packs.js
CHANGED
|
@@ -39,6 +39,14 @@ export const getAllPacks = async () => {
|
|
|
39
39
|
return perms;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
const envSmtp = [
|
|
43
|
+
{ "name": "SMTP_HOST", "value": "smtp.example.com", "description": "SMTP server host for sending emails." },
|
|
44
|
+
{ "name": "SMTP_PORT", "value": "587", "description": "SMTP server port." },
|
|
45
|
+
{ "name": "SMTP_USER", "value": "user@example.com", "description": "Username for SMTP authentication." },
|
|
46
|
+
{ "name": "SMTP_PASS", "value": "your_smtp_password", "description": "Password for SMTP authentication." },
|
|
47
|
+
{ "name": "SMTP_FROM", "value": "\"My Store\" <noreply@example.com>", "description": "The 'From' address for outgoing emails." }
|
|
48
|
+
];
|
|
49
|
+
|
|
42
50
|
const categories = [ 'News', 'Blog', 'Products', 'Services', 'Store', 'Events', 'Forums', 'Contact', 'Support'];
|
|
43
51
|
const tags = [ 'info', 'incident', 'maintenance', 'feature', 'hint', 'bugfix', 'question'];
|
|
44
52
|
|
|
@@ -48,6 +56,242 @@ export const getAllPacks = async () => {
|
|
|
48
56
|
{name: 'visitor', perms: ['API_SEARCH_DATA_webpage','API_SEARCH_DATA_content', 'API_SEARCH_DATA_lang', 'API_SEARCH_DATA_currency', 'API_SEARCH_DATA_taxonomy']}];
|
|
49
57
|
|
|
50
58
|
return [
|
|
59
|
+
{
|
|
60
|
+
"name": "Marketing & Campaigning",
|
|
61
|
+
"description": "Launch powerful, personalized, and scalable email campaigns. This pack uses dynamic audiences and a robust workflow to send emails in chunks, ensuring high performance. Depends on the 'Customer Relationship Management (CRM)' pack.",
|
|
62
|
+
"tags": ["marketing", "email", "campaign", "workflow"],
|
|
63
|
+
"models": ["env", "contact", "workflow", "workflowStep", "workflowAction", "workflowRun", "workflowTrigger",
|
|
64
|
+
{
|
|
65
|
+
"name": "campaign",
|
|
66
|
+
"description": "Defines an email marketing campaign.",
|
|
67
|
+
"fields": [
|
|
68
|
+
{ "name": "name", "type": "string", "required": true, "asMain": true },
|
|
69
|
+
{ "name": "subject", "type": "string", "required": true },
|
|
70
|
+
{ "name": "content", "type": "richtext", "required": true },
|
|
71
|
+
{ "name": "status", "type": "enum", "items": ["draft", "scheduled", "in_progress", "finished", "cancelled"], "default": "draft" },
|
|
72
|
+
{ "name": "audience", "type": "relation", "relation": "audience" },
|
|
73
|
+
{ "name": "processedRecipients", "type": "array", "itemsType": "string", "hint": "List of processed contact IDs." }
|
|
74
|
+
]
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
"name": "audience",
|
|
78
|
+
"description": "Defines a dynamic segment of contacts based on a filter.",
|
|
79
|
+
"fields": [
|
|
80
|
+
{ "name": "name", "type": "string", "required": true, "asMain": true },
|
|
81
|
+
{ "name": "description", "type": "string" },
|
|
82
|
+
{ "name": "filter", "type": "code", "language": "json", "required": true, "conditionBuilder": true, "hint": "A MongoDB filter to select contacts. E.g., { \"tags\": \"newsletter\" }" }
|
|
83
|
+
]
|
|
84
|
+
}
|
|
85
|
+
],
|
|
86
|
+
"data":{
|
|
87
|
+
"all": {
|
|
88
|
+
"audience": [
|
|
89
|
+
{
|
|
90
|
+
"name": "Example Audience for Campaigning",
|
|
91
|
+
"description": "An example audience targeting specific contacts from the CRM pack.",
|
|
92
|
+
"filter": { "$or": [{ "$eq": ["$email", "alice.martin@innovatech.com"] }, { "$eq": ["$email", "bob.durand@globalexports.com"] }] }
|
|
93
|
+
}
|
|
94
|
+
],
|
|
95
|
+
"campaign": [
|
|
96
|
+
{
|
|
97
|
+
"name": "Q3 Product Launch",
|
|
98
|
+
"subject": "🚀 Discover Our New Products!",
|
|
99
|
+
"content": "<h1>Hello {recipient.firstName},</h1><p>We are excited to introduce our latest product line. We think you'll love it!</p><p>Best regards,<br>The Team</p>",
|
|
100
|
+
"status": "draft",
|
|
101
|
+
"audience": { "$link": { "name": "Example Audience for Campaigning", "_model": "audience" } }
|
|
102
|
+
}
|
|
103
|
+
],
|
|
104
|
+
"workflow": [{
|
|
105
|
+
"name": "Campaign Emailing Workflow",
|
|
106
|
+
"description": "A scalable workflow that sends campaign emails in chunks by dynamically querying contacts from an audience.",
|
|
107
|
+
"startStep": { "$link": { "name": "Start Campaign Processing", "_model": "workflowStep" } }
|
|
108
|
+
}],
|
|
109
|
+
"workflowAction": [
|
|
110
|
+
{
|
|
111
|
+
"name": "Set Campaign to 'in_progress'",
|
|
112
|
+
"type": "UpdateData",
|
|
113
|
+
"targetModel": "campaign",
|
|
114
|
+
"targetSelector": { "_id": "{triggerData._id}" },
|
|
115
|
+
"fieldsToUpdate": { "status": "in_progress" }
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
"name": "Get Next Recipient Chunk",
|
|
119
|
+
"type": "ExecuteScript",
|
|
120
|
+
"script": `
|
|
121
|
+
const chunkSize = 10; // Process 10 recipients per run
|
|
122
|
+
const campaign = context.triggerData;
|
|
123
|
+
const audience = await db.findOne("audience",{"_id": campaign.audience});
|
|
124
|
+
|
|
125
|
+
if (!audience || !audience.filter) {
|
|
126
|
+
logger.error('Campaign audience or audience filter is not defined.');
|
|
127
|
+
return { chunk: [], message: 'Campaign audience or audience filter is not defined.'}; // Returning an empty chunk will stop the workflow.
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const processedIds = campaign.processedRecipients || [];
|
|
131
|
+
|
|
132
|
+
const query = {
|
|
133
|
+
'$and': [
|
|
134
|
+
audience.filter,
|
|
135
|
+
{ '$not':{ '$in': ["$_id", processedIds] } }
|
|
136
|
+
]
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
logger.info('Finding next chunk with filter:', JSON.stringify(query));
|
|
140
|
+
|
|
141
|
+
const searchResult = await db.find('contact', query, { limit: chunkSize });
|
|
142
|
+
const chunk = searchResult.data || [];
|
|
143
|
+
|
|
144
|
+
logger.info(\`Found \${chunk.length} recipients for the next chunk.\`);
|
|
145
|
+
|
|
146
|
+
return { chunk }; // This chunk is passed to the next action via context.result
|
|
147
|
+
`
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
"name": "Send Email to Chunk",
|
|
151
|
+
"type": "SendEmail",
|
|
152
|
+
"emailRecipients": ["{context.result.chunk}"],
|
|
153
|
+
"emailSubject": "{triggerData.subject}",
|
|
154
|
+
"emailContent": "{triggerData.content}"
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
"name": "Update Processed Recipients",
|
|
158
|
+
"type": "ExecuteScript",
|
|
159
|
+
"script": `
|
|
160
|
+
const campaignId = context.triggerData._id;
|
|
161
|
+
const emailResult = context.emailResult;
|
|
162
|
+
|
|
163
|
+
if (!emailResult || !Array.isArray(emailResult.sent) || emailResult.sent.length === 0) {
|
|
164
|
+
logger.info('No recipients were successfully sent an email in this chunk.');
|
|
165
|
+
// Return the original chunk from the first script to allow the condition to check it
|
|
166
|
+
return { processedChunk: context.result.chunk };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const processedIds = emailResult.sent.map(recipient => recipient._id.toString());
|
|
170
|
+
|
|
171
|
+
logger.info(\`Updating campaign \${campaignId} with \${processedIds.length} new processed IDs.\`);
|
|
172
|
+
|
|
173
|
+
await db.update(
|
|
174
|
+
'campaign',
|
|
175
|
+
{ _id: campaignId },
|
|
176
|
+
{ 'processedRecipients': [...campaign.processedRecipients, ...processedIds] }
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
// Return the original chunk for the condition check step
|
|
180
|
+
return { processedChunk: context.result.chunk };
|
|
181
|
+
`
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
"name": "Set Campaign to 'finished'",
|
|
185
|
+
"type": "UpdateData",
|
|
186
|
+
"targetModel": "campaign",
|
|
187
|
+
"targetSelector": { "_id": "{triggerData._id}" },
|
|
188
|
+
"fieldsToUpdate": { "status": "finished" }
|
|
189
|
+
}
|
|
190
|
+
],
|
|
191
|
+
"workflowStep": [
|
|
192
|
+
{
|
|
193
|
+
"name": "Start Campaign Processing",
|
|
194
|
+
"workflow": { "$link": { "name": "Campaign Emailing Workflow", "_model": "workflow" } },
|
|
195
|
+
"actions": { "$link": { "name": "Set Campaign to 'in_progress'", "_model": "workflowAction" } },
|
|
196
|
+
"onSuccessStep": { "$link": { "name": "Process Recipient Chunk", "_model": "workflowStep" } }
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
"name": "Process Recipient Chunk",
|
|
200
|
+
"workflow": { "$link": { "name": "Campaign Emailing Workflow", "_model": "workflow" } },
|
|
201
|
+
"actions":
|
|
202
|
+
{ "$link": { "$or": [
|
|
203
|
+
{"$eq": ["$name", "Get Next Recipient Chunk"]},
|
|
204
|
+
{"$eq": ["$name", "Send Email to Chunk"]},
|
|
205
|
+
{"$eq": ["$name", "Update Processed Recipients"]}
|
|
206
|
+
],"_model": "workflowAction"}},
|
|
207
|
+
"onSuccessStep": { "$link": { "name": "Check if Campaign is Complete", "_model": "workflowStep" } }
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
"name": "Check if Campaign is Complete",
|
|
211
|
+
"workflow": { "$link": { "name": "Campaign Emailing Workflow", "_model": "workflow" } },
|
|
212
|
+
"conditions": { "$gt": [{ "$size": {$ifNull:["{context.result.processedChunk}", []]} }, 0] },
|
|
213
|
+
"onSuccessStep": { "$link": { "name": "Process Recipient Chunk", "_model": "workflowStep" } },
|
|
214
|
+
"onFailureStep": { "$link": { "name": "Finish Campaign", "_model": "workflowStep" } }
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
"name": "Finish Campaign",
|
|
218
|
+
"workflow": { "$link": { "name": "Campaign Emailing Workflow", "_model": "workflow" } },
|
|
219
|
+
"actions": { "$link": { "name": "Set Campaign to 'finished'", "_model": "workflowAction" } },
|
|
220
|
+
"isTerminal": true
|
|
221
|
+
}
|
|
222
|
+
],
|
|
223
|
+
"workflowTrigger": [{
|
|
224
|
+
"name": "On Campaign Scheduled",
|
|
225
|
+
"workflow": { "$link": { "name": "Campaign Emailing Workflow", "_model": "workflow" } },
|
|
226
|
+
"type": "manual",
|
|
227
|
+
"onEvent": "DataEdited",
|
|
228
|
+
"targetModel": "campaign",
|
|
229
|
+
"dataFilter": { "$eq": ["$status", "scheduled"] },
|
|
230
|
+
"isActive": true
|
|
231
|
+
}],
|
|
232
|
+
"env": envSmtp
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
/*
|
|
237
|
+
{
|
|
238
|
+
"name": "Social Media Publisher",
|
|
239
|
+
"description": "Automate your social media presence. This pack provides a workflow to automatically post new blog articles to Twitter and LinkedIn. Requires the 'Website Starter Pack'.",
|
|
240
|
+
"tags": ["social media", "marketing", "automation", "workflow"],
|
|
241
|
+
"dependencies": ["Website Starter Pack"],
|
|
242
|
+
"models": ["workflow", "workflowStep", "workflowAction", "workflowTrigger", "env"],
|
|
243
|
+
"data": {
|
|
244
|
+
"all": {
|
|
245
|
+
"workflow": [{
|
|
246
|
+
"name": "Publish New Blog Post to Socials",
|
|
247
|
+
"description": "Automatically posts a link to a new blog post on Twitter and LinkedIn.",
|
|
248
|
+
"startStep": {"$link": {"name": "Post on Social Networks", "_model": "workflowStep"}}
|
|
249
|
+
}],
|
|
250
|
+
"workflowAction": [
|
|
251
|
+
{
|
|
252
|
+
"name": "Post to Twitter",
|
|
253
|
+
"type": "PostToSocialMedia",
|
|
254
|
+
"provider": "Twitter",
|
|
255
|
+
"content": "📰 New blog post published: {triggerData.title}! Check it out here: https://your-website.com/blog/{triggerData.slug}"
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
"name": "Post to LinkedIn",
|
|
259
|
+
"type": "PostToSocialMedia",
|
|
260
|
+
"provider": "LinkedIn",
|
|
261
|
+
"content": "We've just published a new article: '{triggerData.title}'.\n\n{triggerData.summary}\n\nRead the full post on our blog: https://your-website.com/blog/{triggerData.slug}\n#YourIndustry #BlogPost"
|
|
262
|
+
}
|
|
263
|
+
],
|
|
264
|
+
"workflowStep": [{
|
|
265
|
+
"name": "Post on Social Networks",
|
|
266
|
+
"workflow": {"$link": {"name": "Publish New Blog Post to Socials", "_model": "workflow"}},
|
|
267
|
+
"actions": {
|
|
268
|
+
"$link": {
|
|
269
|
+
"$or": [
|
|
270
|
+
{"$eq": ["$name", "Post to Twitter"]},
|
|
271
|
+
{"$eq": ["$name", "Post to LinkedIn"]}
|
|
272
|
+
],
|
|
273
|
+
"_model": "workflowAction"
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
"isTerminal": true
|
|
277
|
+
}],
|
|
278
|
+
"workflowTrigger": [{
|
|
279
|
+
"name": "On New Blog Post Added",
|
|
280
|
+
"workflow": {"$link": {"name": "Publish New Blog Post to Socials", "_model": "workflow"}},
|
|
281
|
+
"type": "manual",
|
|
282
|
+
"onEvent": "DataAdded",
|
|
283
|
+
"targetModel": "content",
|
|
284
|
+
"dataFilter": {"category": {"$find": {"name": "Blog"}}},
|
|
285
|
+
"isActive": true
|
|
286
|
+
}],
|
|
287
|
+
"env": [
|
|
288
|
+
{"name": "TWITTER_API_KEY", "value": "your_key_here"},
|
|
289
|
+
{"name": "TWITTER_API_SECRET", "value": "your_secret_here"},
|
|
290
|
+
{"name": "LINKEDIN_ACCESS_TOKEN", "value": "your_token_here"}
|
|
291
|
+
]
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
},*/
|
|
51
295
|
{
|
|
52
296
|
"name": "E-commerce Starter Kit",
|
|
53
297
|
"description": "Launch your online store in just a few clicks. This pack includes templates for products, orders, and customers, as well as sample data, KPIs, alerts and an order fulfillment workflow (with sending email).",
|
|
@@ -55,13 +299,7 @@ export const getAllPacks = async () => {
|
|
|
55
299
|
"models": ["env", "taxonomy", "product", "productVariant", "brand", "currency", "order", "shipment", "review", "cart", "cartItem", "discount", "workflow", "workflowStep", "workflowAction","workflowRun", "workflowTrigger", "translation", "lang", "kpi", "dashboard", "alert", "return"],
|
|
56
300
|
"data": {
|
|
57
301
|
"all": {
|
|
58
|
-
"env":
|
|
59
|
-
{ "name": "SMTP_HOST", "value": "smtp.example.com", "description": "SMTP server host for sending emails." },
|
|
60
|
-
{ "name": "SMTP_PORT", "value": "587", "description": "SMTP server port." },
|
|
61
|
-
{ "name": "SMTP_USER", "value": "user@example.com", "description": "Username for SMTP authentication." },
|
|
62
|
-
{ "name": "SMTP_PASS", "value": "your_smtp_password", "description": "Password for SMTP authentication." },
|
|
63
|
-
{ "name": "SMTP_FROM", "value": "\"My Store\" <noreply@example.com>", "description": "The 'From' address for outgoing emails." }
|
|
64
|
-
],
|
|
302
|
+
"env":envSmtp,
|
|
65
303
|
"taxonomy": [
|
|
66
304
|
{ "name": "E-commerce", "type": "category" },
|
|
67
305
|
{ "name": "Clothes", "type": "category", "parent": { "$find": { "name": "E-commerce" } } },
|
|
@@ -14,13 +14,14 @@ import {
|
|
|
14
14
|
|
|
15
15
|
import {
|
|
16
16
|
modelsCollection as getAppModelsCollection,
|
|
17
|
-
getCollectionForUser as getAppUserCollection
|
|
17
|
+
getCollectionForUser as getAppUserCollection, getCollectionForUser
|
|
18
18
|
} from 'data-primals-engine/modules/mongodb';
|
|
19
19
|
import process from "node:process";
|
|
20
20
|
|
|
21
21
|
import { dumpUserData, loadFromDump } from 'data-primals-engine/modules/data';
|
|
22
22
|
import fs from "node:fs";
|
|
23
23
|
import {initEngine} from "../src/setenv.js";
|
|
24
|
+
import {getUserCollectionName} from "../src/modules/mongodb.js";
|
|
24
25
|
|
|
25
26
|
vi.mock('data-primals-engine/engine', async(importOriginal) => {
|
|
26
27
|
const mod = await importOriginal() // type is inferred
|
|
@@ -70,7 +71,7 @@ beforeAll(async () => {
|
|
|
70
71
|
fs.readdirSync(backupDir).forEach(file => {
|
|
71
72
|
fs.unlinkSync(path.join(backupDir, file));
|
|
72
73
|
});
|
|
73
|
-
vi.stubEnv('
|
|
74
|
+
vi.stubEnv('ENCRYPTION_KEY', '00000000000000000000000000000000');
|
|
74
75
|
vi.stubEnv('OPENAI_API_KEY', '00000000000000000000000000000000');
|
|
75
76
|
// You might need to create a model first if your dumpUserData requires it
|
|
76
77
|
await createModel(testModelDefinition);
|
|
@@ -88,6 +89,8 @@ afterAll(async () => {
|
|
|
88
89
|
});
|
|
89
90
|
fs.rmdirSync(backupDir); // Remove the directory itself
|
|
90
91
|
}
|
|
92
|
+
const coll = await getCollectionForUser(mockUser);
|
|
93
|
+
await coll.drop()
|
|
91
94
|
});
|
|
92
95
|
|
|
93
96
|
beforeAll(async () =>{
|
|
@@ -1,22 +1,25 @@
|
|
|
1
1
|
// __tests__/data.integration.test.js
|
|
2
2
|
import { ObjectId } from 'mongodb';
|
|
3
|
-
import {expect, describe, it, beforeEach, beforeAll, afterAll} from 'vitest';
|
|
3
|
+
import {vi, expect, describe, it, beforeEach, beforeAll, afterAll} from 'vitest';
|
|
4
4
|
import { Config } from '../src/config.js';
|
|
5
|
-
|
|
6
5
|
import {
|
|
7
6
|
insertData,
|
|
8
7
|
editData,
|
|
9
8
|
deleteData,
|
|
10
|
-
searchData, installPack, deleteModels, createModel
|
|
9
|
+
searchData, installPack, deleteModels, createModel, patchData
|
|
11
10
|
} from 'data-primals-engine/modules/data';
|
|
12
11
|
|
|
13
12
|
import {
|
|
14
13
|
modelsCollection as getAppModelsCollection,
|
|
15
14
|
getCollection,
|
|
16
|
-
getCollectionForUser as getAppUserCollection
|
|
15
|
+
getCollectionForUser as getAppUserCollection, getCollectionForUser
|
|
17
16
|
} from 'data-primals-engine/modules/mongodb';
|
|
18
17
|
import {getRandom} from "../src/core.js";
|
|
19
18
|
import {generateUniqueName, initEngine} from "../src/setenv.js";
|
|
19
|
+
import {processWorkflowRun} from "data-primals-engine/modules/workflow";
|
|
20
|
+
import {sendEmail} from "../src/email.js";
|
|
21
|
+
import {MongoDatabase} from "../src/engine.js";
|
|
22
|
+
import {getUserCollectionName} from "../src/modules/mongodb.js";
|
|
20
23
|
|
|
21
24
|
let testModelsColInstance;
|
|
22
25
|
let testDatasColInstance;
|
|
@@ -828,6 +831,8 @@ describe('Intégration des fonctions CRUD de données avec validation complète'
|
|
|
828
831
|
await deleteModels({ name: orderModel.name, _user: user.username });
|
|
829
832
|
await deleteData(productModel.name, {}, user);
|
|
830
833
|
await deleteData(orderModel.name, {}, user);
|
|
834
|
+
const coll = await getCollectionForUser(user);
|
|
835
|
+
await coll.drop();
|
|
831
836
|
});
|
|
832
837
|
|
|
833
838
|
it('should ALLOW inserting data with a valid relation', async () => {
|
|
@@ -871,4 +876,5 @@ describe('Intégration des fonctions CRUD de données avec validation complète'
|
|
|
871
876
|
await deleteData(orderModel.name, initialOrder.insertedIds, user);
|
|
872
877
|
});
|
|
873
878
|
});
|
|
879
|
+
|
|
874
880
|
});
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// events.test.js
|
|
2
|
+
import { Event } from '../src/events.js';
|
|
3
|
+
import {vitest} from "vitest";
|
|
4
|
+
import {expect, describe, it, beforeEach, beforeAll, afterAll} from 'vitest';
|
|
5
|
+
|
|
6
|
+
describe('Event System', () => {
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
// Clear all events before each test to ensure a clean state
|
|
9
|
+
for (const system in Event) {
|
|
10
|
+
if (Event.hasOwnProperty(system) && typeof Event[system] === 'object') {
|
|
11
|
+
for (const eventName in Event[system]) {
|
|
12
|
+
if (Event[system].hasOwnProperty(eventName)) {
|
|
13
|
+
delete Event[system][eventName];
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('should allow listening for and triggering an event', () => {
|
|
21
|
+
const mockCallback = vitest.fn();
|
|
22
|
+
const eventName = 'testEvent';
|
|
23
|
+
|
|
24
|
+
Event.Listen(eventName, mockCallback);
|
|
25
|
+
Event.Trigger(eventName);
|
|
26
|
+
|
|
27
|
+
expect(mockCallback).toHaveBeenCalledTimes(1);
|
|
28
|
+
});
|
|
29
|
+
it('should pass arguments to the event callback', () => {
|
|
30
|
+
const mockCallback = vitest.fn();
|
|
31
|
+
const eventName = 'testEventWithArgs';
|
|
32
|
+
const arg1 = 'hello';
|
|
33
|
+
const arg2 = 123;
|
|
34
|
+
|
|
35
|
+
Event.Listen(eventName, mockCallback, "priority", "medium");
|
|
36
|
+
Event.Trigger(eventName, "priority", "medium", arg1, arg2);
|
|
37
|
+
|
|
38
|
+
expect(mockCallback).toHaveBeenCalledTimes(1);
|
|
39
|
+
expect(mockCallback).toHaveBeenCalledWith(arg1, arg2);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should allow removing a specific callback', () => {
|
|
43
|
+
const mockCallback1 = vitest.fn();
|
|
44
|
+
const mockCallback2 = vitest.fn();
|
|
45
|
+
const eventName = 'testEventRemoveCallback';
|
|
46
|
+
|
|
47
|
+
Event.Listen(eventName, mockCallback1);
|
|
48
|
+
Event.Listen(eventName, mockCallback2);
|
|
49
|
+
Event.RemoveCallback(eventName, mockCallback1);
|
|
50
|
+
Event.Trigger(eventName);
|
|
51
|
+
|
|
52
|
+
expect(mockCallback1).not.toHaveBeenCalled();
|
|
53
|
+
expect(mockCallback2).toHaveBeenCalledTimes(1);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should not trigger a callback if the event is not listened to', () => {
|
|
57
|
+
const mockCallback = vitest.fn();
|
|
58
|
+
const eventName = 'nonExistentEvent';
|
|
59
|
+
|
|
60
|
+
Event.Trigger(eventName);
|
|
61
|
+
|
|
62
|
+
expect(mockCallback).not.toHaveBeenCalled();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should handle different systems and layers', () => {
|
|
66
|
+
const mockCallbackPriority = vitest.fn();
|
|
67
|
+
const mockCallbackLog = vitest.fn();
|
|
68
|
+
const eventName = 'testEventMultiSystem';
|
|
69
|
+
|
|
70
|
+
Event.Listen(eventName, mockCallbackPriority, 'priority', 'high');
|
|
71
|
+
Event.Listen(eventName, mockCallbackLog, 'log', 'info');
|
|
72
|
+
|
|
73
|
+
Event.Trigger(eventName, 'priority', 'high');
|
|
74
|
+
expect(mockCallbackPriority).toHaveBeenCalledTimes(1);
|
|
75
|
+
expect(mockCallbackLog).not.toHaveBeenCalled();
|
|
76
|
+
|
|
77
|
+
vitest.clearAllMocks(); // Reset mock counts
|
|
78
|
+
|
|
79
|
+
Event.Trigger(eventName, 'log', 'info');
|
|
80
|
+
expect(mockCallbackPriority).not.toHaveBeenCalled();
|
|
81
|
+
expect(mockCallbackLog).toHaveBeenCalledTimes(1);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should throw an error if an invalid system or layer is used with Listen', () => {
|
|
85
|
+
expect(() => {
|
|
86
|
+
Event.Listen('invalidEvent', () => {}, 'invalidSystem', 'invalidLayer');
|
|
87
|
+
}).toThrowError(/System 'invalidSystem' does not exist./);
|
|
88
|
+
|
|
89
|
+
expect(() => {
|
|
90
|
+
Event.Listen('invalidEvent', () => {}, 'priority', 'invalidLayer');
|
|
91
|
+
}).toThrowError(/Layer 'invalidLayer' does not exist/);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('Event.Trigger result merging', () => {
|
|
95
|
+
it('should merge objects from multiple callbacks using spread operator', () => {
|
|
96
|
+
const eventName = 'mergeObjectEvent';
|
|
97
|
+
const callback1 = vitest.fn().mockReturnValue({ a: 1, b: 2 });
|
|
98
|
+
const callback2 = vitest.fn().mockReturnValue({ b: 3, c: 4 }); // b will be overwritten
|
|
99
|
+
|
|
100
|
+
Event.Listen(eventName, callback1);
|
|
101
|
+
Event.Listen(eventName, callback2);
|
|
102
|
+
|
|
103
|
+
const result = Event.Trigger(eventName);
|
|
104
|
+
|
|
105
|
+
expect(result).toEqual({ a: 1, b: 3, c: 4 });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('should concatenate arrays from multiple callbacks', () => {
|
|
109
|
+
const eventName = 'mergeArrayEvent';
|
|
110
|
+
const callback1 = vitest.fn().mockReturnValue([1, 2]);
|
|
111
|
+
const callback2 = vitest.fn().mockReturnValue([3, 4]);
|
|
112
|
+
|
|
113
|
+
Event.Listen(eventName, callback1);
|
|
114
|
+
Event.Listen(eventName, callback2);
|
|
115
|
+
|
|
116
|
+
const result = Event.Trigger(eventName);
|
|
117
|
+
|
|
118
|
+
expect(result).toEqual([1, 2, 3, 4]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('should concatenate strings from multiple callbacks', () => {
|
|
122
|
+
const eventName = 'mergeStringEvent';
|
|
123
|
+
const callback1 = vitest.fn().mockReturnValue('Hello ');
|
|
124
|
+
const callback2 = vitest.fn().mockReturnValue('World');
|
|
125
|
+
|
|
126
|
+
Event.Listen(eventName, callback1);
|
|
127
|
+
Event.Listen(eventName, callback2);
|
|
128
|
+
|
|
129
|
+
const result = Event.Trigger(eventName);
|
|
130
|
+
|
|
131
|
+
expect(result).toBe('Hello World');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('should add numbers from multiple callbacks', () => {
|
|
135
|
+
const eventName = 'mergeNumberEvent';
|
|
136
|
+
const callback1 = vitest.fn().mockReturnValue(10);
|
|
137
|
+
const callback2 = vitest.fn().mockReturnValue(20);
|
|
138
|
+
|
|
139
|
+
Event.Listen(eventName, callback1);
|
|
140
|
+
Event.Listen(eventName, callback2);
|
|
141
|
+
|
|
142
|
+
const result = Event.Trigger(eventName);
|
|
143
|
+
|
|
144
|
+
expect(result).toBe(30);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('should perform a logical AND on booleans from multiple callbacks', () => {
|
|
148
|
+
const eventName = 'mergeBooleanEvent';
|
|
149
|
+
|
|
150
|
+
// Case 1: true && true -> true
|
|
151
|
+
const cb_true1 = vitest.fn().mockReturnValue(true);
|
|
152
|
+
const cb_true2 = vitest.fn().mockReturnValue(true);
|
|
153
|
+
Event.Listen(eventName, cb_true1);
|
|
154
|
+
Event.Listen(eventName, cb_true2);
|
|
155
|
+
expect(Event.Trigger(eventName)).toBe(true);
|
|
156
|
+
Event.RemoveAllCallbacks(eventName); // Clean up for next case
|
|
157
|
+
|
|
158
|
+
// Case 2: true && false -> false
|
|
159
|
+
const cb_false1 = vitest.fn().mockReturnValue(false);
|
|
160
|
+
Event.Listen(eventName, cb_true1);
|
|
161
|
+
Event.Listen(eventName, cb_false1);
|
|
162
|
+
expect(Event.Trigger(eventName)).toBe(false);
|
|
163
|
+
Event.RemoveAllCallbacks(eventName);
|
|
164
|
+
|
|
165
|
+
// Case 3: false && true -> false
|
|
166
|
+
Event.Listen(eventName, cb_false1);
|
|
167
|
+
Event.Listen(eventName, cb_true1);
|
|
168
|
+
expect(Event.Trigger(eventName)).toBe(false);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('should handle mixed-type return values', () => {
|
|
172
|
+
const eventName = 'mergeMixedEvent';
|
|
173
|
+
const callback1 = vitest.fn().mockReturnValue(100); // number
|
|
174
|
+
const callback2 = vitest.fn().mockReturnValue({ a: 1 }); // object
|
|
175
|
+
|
|
176
|
+
Event.Listen(eventName, callback1);
|
|
177
|
+
Event.Listen(eventName, callback2);
|
|
178
|
+
|
|
179
|
+
const result = Event.Trigger(eventName);
|
|
180
|
+
|
|
181
|
+
// The logic initializes `ret` with the first value (100).
|
|
182
|
+
// When the second callback returns an object, it re-initializes `ret` to {}
|
|
183
|
+
// and merges the object. The final result is the object.
|
|
184
|
+
expect(result).toEqual({ a: 1 });
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('should ignore null and undefined return values while merging', () => {
|
|
188
|
+
const eventName = 'ignoreNullsEvent';
|
|
189
|
+
const callback1 = vitest.fn().mockReturnValue(null);
|
|
190
|
+
const callback2 = vitest.fn().mockReturnValue({ data: 'important' });
|
|
191
|
+
const callback3 = vitest.fn().mockReturnValue(undefined);
|
|
192
|
+
|
|
193
|
+
Event.Listen(eventName, callback1);
|
|
194
|
+
Event.Listen(eventName, callback2);
|
|
195
|
+
Event.Listen(eventName, callback3);
|
|
196
|
+
|
|
197
|
+
const result = Event.Trigger(eventName);
|
|
198
|
+
|
|
199
|
+
expect(result).toEqual({ data: 'important' });
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
});
|