eventmodeler 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/dist/index.js +63 -0
  2. package/dist/lib/element-lookup.d.ts +47 -0
  3. package/dist/lib/element-lookup.js +86 -0
  4. package/dist/lib/slice-utils.d.ts +83 -0
  5. package/dist/lib/slice-utils.js +135 -0
  6. package/dist/slices/add-field/index.js +4 -33
  7. package/dist/slices/add-scenario/index.js +15 -77
  8. package/dist/slices/create-automation-slice/index.d.ts +2 -0
  9. package/dist/slices/create-automation-slice/index.js +217 -0
  10. package/dist/slices/create-flow/index.d.ts +2 -0
  11. package/dist/slices/create-flow/index.js +177 -0
  12. package/dist/slices/create-state-change-slice/index.d.ts +2 -0
  13. package/dist/slices/create-state-change-slice/index.js +239 -0
  14. package/dist/slices/create-state-view-slice/index.d.ts +2 -0
  15. package/dist/slices/create-state-view-slice/index.js +120 -0
  16. package/dist/slices/list-chapters/index.js +2 -2
  17. package/dist/slices/list-commands/index.js +2 -2
  18. package/dist/slices/list-events/index.js +3 -2
  19. package/dist/slices/list-slices/index.js +2 -2
  20. package/dist/slices/mark-slice-status/index.js +2 -11
  21. package/dist/slices/remove-field/index.js +4 -33
  22. package/dist/slices/remove-scenario/index.js +45 -11
  23. package/dist/slices/show-actor/index.js +2 -11
  24. package/dist/slices/show-aggregate-completeness/index.js +2 -11
  25. package/dist/slices/show-chapter/index.js +6 -14
  26. package/dist/slices/show-command/index.js +4 -12
  27. package/dist/slices/show-completeness/index.js +378 -32
  28. package/dist/slices/show-event/index.js +4 -12
  29. package/dist/slices/show-slice/index.js +14 -17
  30. package/dist/slices/update-field/index.js +4 -33
  31. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -31,6 +31,10 @@ import { mapFields } from './slices/map-fields/index.js';
31
31
  import { updateField } from './slices/update-field/index.js';
32
32
  import { showAggregateCompleteness, listAggregates } from './slices/show-aggregate-completeness/index.js';
33
33
  import { showActor, listActors } from './slices/show-actor/index.js';
34
+ import { createStateChangeSlice } from './slices/create-state-change-slice/index.js';
35
+ import { createAutomationSlice } from './slices/create-automation-slice/index.js';
36
+ import { createStateViewSlice } from './slices/create-state-view-slice/index.js';
37
+ import { createFlow } from './slices/create-flow/index.js';
34
38
  const args = process.argv.slice(2);
35
39
  function getNamedArg(argList, ...names) {
36
40
  for (let i = 0; i < argList.length; i++) {
@@ -87,6 +91,15 @@ COMMANDS:
87
91
  update field --command|--event|--read-model <name> --field <name> [--optional true|false] [--generated true|false]
88
92
  Update field properties
89
93
 
94
+ create state-change-slice --xml <data>
95
+ Create a state-change slice (Screen → Command → Event)
96
+ create automation-slice --xml <data>
97
+ Create an automation slice (Processor → Command → Event)
98
+ create state-view-slice --xml <data>
99
+ Create a state-view slice (Read Model)
100
+ create flow --from <source> --to <target>
101
+ Create a flow between elements (Event→ReadModel, ReadModel→Screen/Processor)
102
+
90
103
  summary Show model summary statistics
91
104
 
92
105
  export json Export entire model as JSON
@@ -413,6 +426,56 @@ async function main() {
413
426
  process.exit(1);
414
427
  }
415
428
  break;
429
+ case 'create':
430
+ switch (subcommand) {
431
+ case 'state-change-slice': {
432
+ const xmlArg = getNamedArg(filteredArgs, '--xml');
433
+ if (!xmlArg) {
434
+ console.error('Error: --xml is required');
435
+ console.error('Usage: eventmodeler create state-change-slice --xml \'<state-change-slice>...</state-change-slice>\'');
436
+ process.exit(1);
437
+ }
438
+ createStateChangeSlice(model, filePath, xmlArg);
439
+ break;
440
+ }
441
+ case 'automation-slice': {
442
+ const xmlArg = getNamedArg(filteredArgs, '--xml');
443
+ if (!xmlArg) {
444
+ console.error('Error: --xml is required');
445
+ console.error('Usage: eventmodeler create automation-slice --xml \'<automation-slice>...</automation-slice>\'');
446
+ process.exit(1);
447
+ }
448
+ createAutomationSlice(model, filePath, xmlArg);
449
+ break;
450
+ }
451
+ case 'state-view-slice': {
452
+ const xmlArg = getNamedArg(filteredArgs, '--xml');
453
+ if (!xmlArg) {
454
+ console.error('Error: --xml is required');
455
+ console.error('Usage: eventmodeler create state-view-slice --xml \'<state-view-slice>...</state-view-slice>\'');
456
+ process.exit(1);
457
+ }
458
+ createStateViewSlice(model, filePath, xmlArg);
459
+ break;
460
+ }
461
+ case 'flow': {
462
+ const fromArg = getNamedArg(filteredArgs, '--from');
463
+ const toArg = getNamedArg(filteredArgs, '--to');
464
+ if (!fromArg || !toArg) {
465
+ console.error('Error: --from and --to are required');
466
+ console.error('Usage: eventmodeler create flow --from <source> --to <target>');
467
+ console.error('Valid flows: Event→ReadModel, ReadModel→Screen, ReadModel→Processor');
468
+ process.exit(1);
469
+ }
470
+ createFlow(model, filePath, fromArg, toArg);
471
+ break;
472
+ }
473
+ default:
474
+ console.error(`Unknown create target: ${subcommand}`);
475
+ console.error('Valid targets: state-change-slice, automation-slice, state-view-slice, flow');
476
+ process.exit(1);
477
+ }
478
+ break;
416
479
  default:
417
480
  console.error(`Unknown command: ${command}`);
418
481
  printHelp();
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Element lookup utilities for CLI commands.
3
+ * Provides exact-match lookup with UUID disambiguation for duplicate names.
4
+ */
5
+ export type LookupResult<T> = {
6
+ success: true;
7
+ element: T;
8
+ } | {
9
+ success: false;
10
+ error: 'not_found' | 'ambiguous';
11
+ matches: T[];
12
+ };
13
+ /**
14
+ * Find an element by exact name (case-insensitive) or UUID.
15
+ * - If search starts with "id:", treats rest as UUID/UUID prefix
16
+ * - Otherwise, performs case-insensitive exact name match
17
+ * - Returns error if multiple elements have the same name
18
+ */
19
+ export declare function findElement<T extends {
20
+ id: string;
21
+ name: string;
22
+ }>(elements: Map<string, T> | T[], search: string): LookupResult<T>;
23
+ /**
24
+ * Format an element name with its ID for display
25
+ */
26
+ export declare function formatElementWithId<T extends {
27
+ id: string;
28
+ name: string;
29
+ }>(element: T, truncateId?: boolean): string;
30
+ /**
31
+ * Print error message for lookup failures and exit
32
+ */
33
+ export declare function handleLookupError<T extends {
34
+ id: string;
35
+ name: string;
36
+ }>(search: string, elementType: string, result: {
37
+ success: false;
38
+ error: 'not_found' | 'ambiguous';
39
+ matches: T[];
40
+ }, allElements: Map<string, T> | T[]): never;
41
+ /**
42
+ * Convenience function: find element or exit with error
43
+ */
44
+ export declare function findElementOrExit<T extends {
45
+ id: string;
46
+ name: string;
47
+ }>(elements: Map<string, T> | T[], search: string, elementType: string): T;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Element lookup utilities for CLI commands.
3
+ * Provides exact-match lookup with UUID disambiguation for duplicate names.
4
+ */
5
+ /**
6
+ * Find an element by exact name (case-insensitive) or UUID.
7
+ * - If search starts with "id:", treats rest as UUID/UUID prefix
8
+ * - Otherwise, performs case-insensitive exact name match
9
+ * - Returns error if multiple elements have the same name
10
+ */
11
+ export function findElement(elements, search) {
12
+ const elementArray = Array.isArray(elements) ? elements : [...elements.values()];
13
+ // Check for UUID lookup (id:prefix or full UUID format)
14
+ if (search.startsWith('id:')) {
15
+ const idSearch = search.slice(3).toLowerCase();
16
+ const match = elementArray.find(e => e.id.toLowerCase().startsWith(idSearch));
17
+ if (match) {
18
+ return { success: true, element: match };
19
+ }
20
+ return { success: false, error: 'not_found', matches: [] };
21
+ }
22
+ // Check if the search looks like a UUID (contains dashes in UUID pattern)
23
+ const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
24
+ if (uuidPattern.test(search)) {
25
+ const match = elementArray.find(e => e.id.toLowerCase() === search.toLowerCase());
26
+ if (match) {
27
+ return { success: true, element: match };
28
+ }
29
+ return { success: false, error: 'not_found', matches: [] };
30
+ }
31
+ // Case-insensitive exact name match
32
+ const searchLower = search.toLowerCase();
33
+ const matches = elementArray.filter(e => e.name.toLowerCase() === searchLower);
34
+ if (matches.length === 1) {
35
+ return { success: true, element: matches[0] };
36
+ }
37
+ if (matches.length > 1) {
38
+ return { success: false, error: 'ambiguous', matches };
39
+ }
40
+ return { success: false, error: 'not_found', matches: [] };
41
+ }
42
+ /**
43
+ * Format an element name with its ID for display
44
+ */
45
+ export function formatElementWithId(element, truncateId = true) {
46
+ const id = truncateId ? element.id.slice(0, 8) : element.id;
47
+ return `"${element.name}" (id: ${id})`;
48
+ }
49
+ /**
50
+ * Print error message for lookup failures and exit
51
+ */
52
+ export function handleLookupError(search, elementType, result, allElements) {
53
+ const elementArray = Array.isArray(allElements) ? allElements : [...allElements.values()];
54
+ if (result.error === 'ambiguous') {
55
+ console.error(`Error: Multiple ${elementType}s found with name "${search}"`);
56
+ console.error('Please specify using the element ID:');
57
+ for (const el of result.matches) {
58
+ console.error(` - ${formatElementWithId(el, false)}`);
59
+ }
60
+ console.error('');
61
+ console.error(`Usage: eventmodeler <command> "id:${result.matches[0].id.slice(0, 8)}"`);
62
+ }
63
+ else {
64
+ console.error(`Error: ${elementType.charAt(0).toUpperCase() + elementType.slice(1)} not found: "${search}"`);
65
+ if (elementArray.length > 0) {
66
+ console.error(`Available ${elementType}s:`);
67
+ for (const el of elementArray) {
68
+ console.error(` - ${formatElementWithId(el)}`);
69
+ }
70
+ }
71
+ else {
72
+ console.error(`No ${elementType}s exist in the model.`);
73
+ }
74
+ }
75
+ process.exit(1);
76
+ }
77
+ /**
78
+ * Convenience function: find element or exit with error
79
+ */
80
+ export function findElementOrExit(elements, search, elementType) {
81
+ const result = findElement(elements, search);
82
+ if (!result.success) {
83
+ handleLookupError(search, elementType, result, elements);
84
+ }
85
+ return result.element;
86
+ }
@@ -0,0 +1,83 @@
1
+ import type { EventModel, Field, FieldMapping, Slice } from '../types.js';
2
+ export declare const STATE_CHANGE_SLICE: {
3
+ width: number;
4
+ height: number;
5
+ screen: {
6
+ width: number;
7
+ height: number;
8
+ offsetX: number;
9
+ offsetY: number;
10
+ };
11
+ command: {
12
+ width: number;
13
+ height: number;
14
+ offsetX: number;
15
+ offsetY: number;
16
+ };
17
+ event: {
18
+ width: number;
19
+ height: number;
20
+ offsetX: number;
21
+ offsetY: number;
22
+ };
23
+ };
24
+ export declare const AUTOMATION_SLICE: {
25
+ width: number;
26
+ height: number;
27
+ processor: {
28
+ width: number;
29
+ height: number;
30
+ offsetX: number;
31
+ offsetY: number;
32
+ };
33
+ command: {
34
+ width: number;
35
+ height: number;
36
+ offsetX: number;
37
+ offsetY: number;
38
+ };
39
+ event: {
40
+ width: number;
41
+ height: number;
42
+ offsetX: number;
43
+ offsetY: number;
44
+ };
45
+ };
46
+ export declare const STATE_VIEW_SLICE: {
47
+ width: number;
48
+ height: number;
49
+ readModel: {
50
+ width: number;
51
+ height: number;
52
+ offsetX: number;
53
+ offsetY: number;
54
+ };
55
+ };
56
+ export declare const SLICE_GAP = 20;
57
+ export interface PositionReference {
58
+ after?: string;
59
+ before?: string;
60
+ }
61
+ export declare function findSliceByName(model: EventModel, name: string): Slice | undefined;
62
+ export declare function validateSliceNameUnique(model: EventModel, name: string): void;
63
+ export declare function calculateSlicePosition(model: EventModel, sliceWidth: number, ref: PositionReference): {
64
+ x: number;
65
+ y: number;
66
+ };
67
+ export declare function getSlicesToShift(model: EventModel, insertX: number, shiftAmount: number): Array<{
68
+ sliceId: string;
69
+ newX: number;
70
+ currentY: number;
71
+ }>;
72
+ export declare function inferFieldMappings(sourceFields: Field[], targetFields: Field[]): FieldMapping[];
73
+ export interface FieldInput {
74
+ name: string;
75
+ type: string;
76
+ isList?: boolean;
77
+ isGenerated?: boolean;
78
+ isOptional?: boolean;
79
+ isUserInput?: boolean;
80
+ subfields?: FieldInput[];
81
+ }
82
+ export declare function parseFieldsFromXml(xml: string): FieldInput[];
83
+ export declare function fieldInputToField(input: FieldInput): Field;
@@ -0,0 +1,135 @@
1
+ // Fixed dimensions from requirements
2
+ export const STATE_CHANGE_SLICE = {
3
+ width: 560,
4
+ height: 1000,
5
+ screen: { width: 180, height: 120, offsetX: 40, offsetY: 120 },
6
+ command: { width: 160, height: 100, offsetX: 180, offsetY: 460 },
7
+ event: { width: 160, height: 100, offsetX: 360, offsetY: 860 },
8
+ };
9
+ export const AUTOMATION_SLICE = {
10
+ width: 560,
11
+ height: 1000,
12
+ processor: { width: 120, height: 120, offsetX: 40, offsetY: 120 },
13
+ command: { width: 160, height: 100, offsetX: 180, offsetY: 460 },
14
+ event: { width: 160, height: 100, offsetX: 360, offsetY: 860 },
15
+ };
16
+ export const STATE_VIEW_SLICE = {
17
+ width: 380,
18
+ height: 1000,
19
+ readModel: { width: 160, height: 100, offsetX: 110, offsetY: 460 },
20
+ };
21
+ export const SLICE_GAP = 20;
22
+ export function findSliceByName(model, name) {
23
+ const nameLower = name.toLowerCase();
24
+ return [...model.slices.values()].find(s => s.name.toLowerCase() === nameLower || s.name.toLowerCase().includes(nameLower));
25
+ }
26
+ export function validateSliceNameUnique(model, name) {
27
+ const existing = findSliceByName(model, name);
28
+ if (existing && existing.name.toLowerCase() === name.toLowerCase()) {
29
+ throw new Error(`Slice "${name}" already exists`);
30
+ }
31
+ }
32
+ export function calculateSlicePosition(model, sliceWidth, ref) {
33
+ const slices = [...model.slices.values()];
34
+ if (slices.length === 0) {
35
+ return { x: 0, y: 0 };
36
+ }
37
+ if (ref.after) {
38
+ const refSlice = findSliceByName(model, ref.after);
39
+ if (!refSlice) {
40
+ const available = slices.map(s => s.name).join(', ');
41
+ throw new Error(`Slice "${ref.after}" not found. Available: ${available}`);
42
+ }
43
+ return {
44
+ x: refSlice.position.x + refSlice.size.width + SLICE_GAP,
45
+ y: refSlice.position.y
46
+ };
47
+ }
48
+ if (ref.before) {
49
+ const refSlice = findSliceByName(model, ref.before);
50
+ if (!refSlice) {
51
+ const available = slices.map(s => s.name).join(', ');
52
+ throw new Error(`Slice "${ref.before}" not found. Available: ${available}`);
53
+ }
54
+ return {
55
+ x: refSlice.position.x,
56
+ y: refSlice.position.y
57
+ };
58
+ }
59
+ // Default: append to end (right of rightmost slice)
60
+ const rightmost = slices.reduce((max, s) => Math.max(max, s.position.x + s.size.width), 0);
61
+ const avgY = slices.reduce((sum, s) => sum + s.position.y, 0) / slices.length;
62
+ return {
63
+ x: rightmost + SLICE_GAP,
64
+ y: avgY
65
+ };
66
+ }
67
+ export function getSlicesToShift(model, insertX, shiftAmount) {
68
+ return [...model.slices.values()]
69
+ .filter(s => s.position.x >= insertX)
70
+ .map(s => ({
71
+ sliceId: s.id,
72
+ newX: s.position.x + shiftAmount,
73
+ currentY: s.position.y
74
+ }));
75
+ }
76
+ export function inferFieldMappings(sourceFields, targetFields) {
77
+ const mappings = [];
78
+ for (const targetField of targetFields) {
79
+ // Skip generated fields - they have no source
80
+ if (targetField.isGenerated)
81
+ continue;
82
+ // Find source field with matching name (case-insensitive)
83
+ const sourceField = sourceFields.find(sf => sf.name.toLowerCase() === targetField.name.toLowerCase());
84
+ if (sourceField) {
85
+ mappings.push({
86
+ sourceFieldId: sourceField.id,
87
+ targetFieldId: targetField.id
88
+ });
89
+ }
90
+ }
91
+ return mappings;
92
+ }
93
+ function getAttr(attrs, name) {
94
+ const match = attrs.match(new RegExp(`${name}="([^"]*)"`));
95
+ return match ? match[1] : undefined;
96
+ }
97
+ export function parseFieldsFromXml(xml) {
98
+ const fields = [];
99
+ // Match top-level field elements only (not nested inside other fields)
100
+ const fieldMatches = xml.matchAll(/<field([^>]*?)(?:\/>|>([\s\S]*?)<\/field>)/g);
101
+ for (const match of fieldMatches) {
102
+ const attrs = match[1];
103
+ const content = match[2];
104
+ const name = getAttr(attrs, 'name');
105
+ const type = getAttr(attrs, 'type');
106
+ if (!name || !type)
107
+ continue;
108
+ const field = {
109
+ name,
110
+ type,
111
+ isList: getAttr(attrs, 'list') === 'true',
112
+ isGenerated: getAttr(attrs, 'generated') === 'true',
113
+ isOptional: getAttr(attrs, 'optional') === 'true',
114
+ isUserInput: getAttr(attrs, 'user-input') === 'true',
115
+ };
116
+ // Parse nested subfields for Custom type
117
+ if (type === 'Custom' && content) {
118
+ field.subfields = parseFieldsFromXml(content);
119
+ }
120
+ fields.push(field);
121
+ }
122
+ return fields;
123
+ }
124
+ export function fieldInputToField(input) {
125
+ return {
126
+ id: crypto.randomUUID(),
127
+ name: input.name,
128
+ fieldType: input.type,
129
+ isList: input.isList ?? false,
130
+ isGenerated: input.isGenerated ?? false,
131
+ isOptional: input.isOptional,
132
+ isUserInput: input.isUserInput,
133
+ subfields: input.subfields?.map(fieldInputToField),
134
+ };
135
+ }
@@ -1,5 +1,6 @@
1
1
  import * as crypto from 'node:crypto';
2
2
  import { appendEvent } from '../../lib/file-loader.js';
3
+ import { findElementOrExit } from '../../lib/element-lookup.js';
3
4
  const validFieldTypes = ['UUID', 'Boolean', 'Double', 'Decimal', 'Date', 'DateTime', 'Long', 'Int', 'String', 'Custom'];
4
5
  function parseJsonInput(input) {
5
6
  return JSON.parse(input);
@@ -103,17 +104,7 @@ export function addField(model, filePath, options, input) {
103
104
  }
104
105
  }
105
106
  function addFieldToCommand(model, filePath, commandName, fieldInput) {
106
- const nameLower = commandName.toLowerCase();
107
- const commands = [...model.commands.values()];
108
- const command = commands.find(c => c.name.toLowerCase() === nameLower || c.name.toLowerCase().includes(nameLower));
109
- if (!command) {
110
- console.error(`Error: Command not found: ${commandName}`);
111
- console.error('Available commands:');
112
- for (const c of commands) {
113
- console.error(` - ${c.name}`);
114
- }
115
- process.exit(1);
116
- }
107
+ const command = findElementOrExit(model.commands, commandName, 'command');
117
108
  // Check for duplicate field name
118
109
  if (command.fields.some(f => f.name.toLowerCase() === fieldInput.name.toLowerCase())) {
119
110
  console.error(`Error: Field "${fieldInput.name}" already exists on command "${command.name}"`);
@@ -129,17 +120,7 @@ function addFieldToCommand(model, filePath, commandName, fieldInput) {
129
120
  console.log(`Added field "${field.name}" to command "${command.name}"`);
130
121
  }
131
122
  function addFieldToEvent(model, filePath, eventName, fieldInput) {
132
- const nameLower = eventName.toLowerCase();
133
- const events = [...model.events.values()];
134
- const event = events.find(e => e.name.toLowerCase() === nameLower || e.name.toLowerCase().includes(nameLower));
135
- if (!event) {
136
- console.error(`Error: Event not found: ${eventName}`);
137
- console.error('Available events:');
138
- for (const e of events) {
139
- console.error(` - ${e.name}`);
140
- }
141
- process.exit(1);
142
- }
123
+ const event = findElementOrExit(model.events, eventName, 'event');
143
124
  // Check for duplicate field name
144
125
  if (event.fields.some(f => f.name.toLowerCase() === fieldInput.name.toLowerCase())) {
145
126
  console.error(`Error: Field "${fieldInput.name}" already exists on event "${event.name}"`);
@@ -155,17 +136,7 @@ function addFieldToEvent(model, filePath, eventName, fieldInput) {
155
136
  console.log(`Added field "${field.name}" to event "${event.name}"`);
156
137
  }
157
138
  function addFieldToReadModel(model, filePath, readModelName, fieldInput) {
158
- const nameLower = readModelName.toLowerCase();
159
- const readModels = [...model.readModels.values()];
160
- const readModel = readModels.find(rm => rm.name.toLowerCase() === nameLower || rm.name.toLowerCase().includes(nameLower));
161
- if (!readModel) {
162
- console.error(`Error: Read model not found: ${readModelName}`);
163
- console.error('Available read models:');
164
- for (const rm of readModels) {
165
- console.error(` - ${rm.name}`);
166
- }
167
- process.exit(1);
168
- }
139
+ const readModel = findElementOrExit(model.readModels, readModelName, 'read model');
169
140
  // Check for duplicate field name
170
141
  if (readModel.fields.some(f => f.name.toLowerCase() === fieldInput.name.toLowerCase())) {
171
142
  console.error(`Error: Field "${fieldInput.name}" already exists on read model "${readModel.name}"`);
@@ -1,5 +1,6 @@
1
1
  import * as crypto from 'node:crypto';
2
2
  import { appendEvent } from '../../lib/file-loader.js';
3
+ import { findElementOrExit } from '../../lib/element-lookup.js';
3
4
  function parseJsonInput(input) {
4
5
  return JSON.parse(input);
5
6
  }
@@ -144,27 +145,12 @@ export function addScenario(model, filePath, sliceName, input) {
144
145
  process.exit(1);
145
146
  }
146
147
  // Find slice by name
147
- const slices = [...model.slices.values()];
148
- const sliceNameLower = sliceName.toLowerCase();
149
- const slice = slices.find(s => s.name.toLowerCase() === sliceNameLower || s.name.toLowerCase().includes(sliceNameLower));
150
- if (!slice) {
151
- console.error(`Error: Slice not found: ${sliceName}`);
152
- console.error('Available slices:');
153
- for (const s of slices) {
154
- console.error(` - ${s.name}`);
155
- }
156
- process.exit(1);
157
- }
148
+ const slice = findElementOrExit(model.slices, sliceName, 'slice');
158
149
  // Resolve event references in given
159
150
  const givenEvents = [];
160
151
  if (scenarioInput.given) {
161
152
  for (const g of scenarioInput.given) {
162
- const event = findEventByName(model, g.event);
163
- if (!event) {
164
- console.error(`Error: Event not found in "given": ${g.event}`);
165
- listAvailableEvents(model);
166
- process.exit(1);
167
- }
153
+ const event = findElementOrExit(model.events, g.event, 'event');
168
154
  givenEvents.push({
169
155
  eventStickyId: event.id,
170
156
  fieldValues: g.fieldValues,
@@ -174,12 +160,7 @@ export function addScenario(model, filePath, sliceName, input) {
174
160
  // Resolve command reference in when
175
161
  let whenCommand = null;
176
162
  if (scenarioInput.when) {
177
- const command = findCommandByName(model, scenarioInput.when.command);
178
- if (!command) {
179
- console.error(`Error: Command not found in "when": ${scenarioInput.when.command}`);
180
- listAvailableCommands(model);
181
- process.exit(1);
182
- }
163
+ const command = findElementOrExit(model.commands, scenarioInput.when.command, 'command');
183
164
  whenCommand = {
184
165
  commandStickyId: command.id,
185
166
  fieldValues: scenarioInput.when.fieldValues,
@@ -195,12 +176,7 @@ export function addScenario(model, filePath, sliceName, input) {
195
176
  then.expectedEvents = [];
196
177
  if (scenarioInput.then.events) {
197
178
  for (const e of scenarioInput.then.events) {
198
- const event = findEventByName(model, e.event);
199
- if (!event) {
200
- console.error(`Error: Event not found in "then.events": ${e.event}`);
201
- listAvailableEvents(model);
202
- process.exit(1);
203
- }
179
+ const event = findElementOrExit(model.events, e.event, 'event');
204
180
  then.expectedEvents.push({
205
181
  eventStickyId: event.id,
206
182
  fieldValues: e.fieldValues,
@@ -213,21 +189,11 @@ export function addScenario(model, filePath, sliceName, input) {
213
189
  console.error('Error: readModelAssertion requires a readModel name');
214
190
  process.exit(1);
215
191
  }
216
- const readModel = findReadModelByName(model, scenarioInput.then.readModel);
217
- if (!readModel) {
218
- console.error(`Error: Read model not found: ${scenarioInput.then.readModel}`);
219
- listAvailableReadModels(model);
220
- process.exit(1);
221
- }
192
+ const readModel = findElementOrExit(model.readModels, scenarioInput.then.readModel, 'read model');
222
193
  const assertionGivenEvents = [];
223
194
  if (scenarioInput.then.givenEvents) {
224
195
  for (const g of scenarioInput.then.givenEvents) {
225
- const event = findEventByName(model, g.event);
226
- if (!event) {
227
- console.error(`Error: Event not found in "then.givenEvents": ${g.event}`);
228
- listAvailableEvents(model);
229
- process.exit(1);
230
- }
196
+ const event = findElementOrExit(model.events, g.event, 'event');
231
197
  assertionGivenEvents.push({
232
198
  eventStickyId: event.id,
233
199
  fieldValues: g.fieldValues,
@@ -241,15 +207,20 @@ export function addScenario(model, filePath, sliceName, input) {
241
207
  };
242
208
  }
243
209
  // Calculate position below slice
210
+ // Scenarios are tall (GWT rows with 100px stickies + labels + padding = ~400px+)
211
+ // so we need adequate spacing between them
212
+ const SCENARIO_GAP = 20; // Match slice gap
213
+ const SCENARIO_ESTIMATED_HEIGHT = 450; // Approximate rendered height for positioning
244
214
  const existingScenarios = [...model.scenarios.values()].filter(s => s.sliceId === slice.id);
245
215
  const sliceBottom = slice.position.y + slice.size.height;
246
216
  let positionY;
247
217
  if (existingScenarios.length === 0) {
248
- positionY = sliceBottom + 20;
218
+ positionY = sliceBottom + SCENARIO_GAP;
249
219
  }
250
220
  else {
251
- const lowestY = Math.max(...existingScenarios.map(s => s.position.y + s.height));
252
- positionY = lowestY + 10;
221
+ // Use estimated height since stored height (80px) doesn't reflect actual rendered size
222
+ const lowestY = Math.max(...existingScenarios.map(s => s.position.y + SCENARIO_ESTIMATED_HEIGHT));
223
+ positionY = lowestY + SCENARIO_GAP;
253
224
  }
254
225
  const position = {
255
226
  x: slice.position.x + 10,
@@ -304,36 +275,3 @@ export function addScenario(model, filePath, sliceName, input) {
304
275
  });
305
276
  console.log(`Added scenario "${scenarioInput.name}" to slice "${slice.name}"`);
306
277
  }
307
- function findEventByName(model, name) {
308
- const nameLower = name.toLowerCase();
309
- const events = [...model.events.values()];
310
- return events.find(e => e.name.toLowerCase() === nameLower || e.name.toLowerCase().includes(nameLower));
311
- }
312
- function findCommandByName(model, name) {
313
- const nameLower = name.toLowerCase();
314
- const commands = [...model.commands.values()];
315
- return commands.find(c => c.name.toLowerCase() === nameLower || c.name.toLowerCase().includes(nameLower));
316
- }
317
- function findReadModelByName(model, name) {
318
- const nameLower = name.toLowerCase();
319
- const readModels = [...model.readModels.values()];
320
- return readModels.find(rm => rm.name.toLowerCase() === nameLower || rm.name.toLowerCase().includes(nameLower));
321
- }
322
- function listAvailableEvents(model) {
323
- console.error('Available events:');
324
- for (const e of model.events.values()) {
325
- console.error(` - ${e.name}`);
326
- }
327
- }
328
- function listAvailableCommands(model) {
329
- console.error('Available commands:');
330
- for (const c of model.commands.values()) {
331
- console.error(` - ${c.name}`);
332
- }
333
- }
334
- function listAvailableReadModels(model) {
335
- console.error('Available read models:');
336
- for (const rm of model.readModels.values()) {
337
- console.error(` - ${rm.name}`);
338
- }
339
- }
@@ -0,0 +1,2 @@
1
+ import type { EventModel } from '../../types.js';
2
+ export declare function createAutomationSlice(model: EventModel, filePath: string, xmlInput: string): void;