diff --git a/README.md b/README.md index 32d55168..8d03799f 100644 --- a/README.md +++ b/README.md @@ -45,13 +45,13 @@ bpmn guide --short # the agent cheat sheet (`bpmn guide` for all ``` $ bpmn new order.bpmn --name "Order handling" $ bpmn add order.bpmn start "Order received" -$ bpmn add order.bpmn userTask "Check invoice" --after Event_OrderReceived -$ bpmn add order.bpmn end "Done" --after Activity_CheckInvoice +$ bpmn add order.bpmn userTask "Check invoice" --after startEvent_orderReceived +$ bpmn add order.bpmn end "Done" --after userTask_checkInvoice $ bpmn show order.bpmn -process Process_OrderHandling "Order handling" executable - startEvent Event_OrderReceived "Order received" -> Activity_CheckInvoice (Flow_OrderReceivedToCheckInvoice) - userTask Activity_CheckInvoice "Check invoice" -> Event_Done (Flow_CheckInvoiceToDone) - endEvent Event_Done "Done" +process orderHandling "Order handling" executable + startEvent startEvent_orderReceived "Order received" -> userTask_checkInvoice (flow_orderReceivedToCheckInvoice) + userTask userTask_checkInvoice "Check invoice" -> endEvent_done (flow_checkInvoiceToDone) + endEvent endEvent_done "Done" problems: none ``` @@ -97,8 +97,10 @@ Details: [Camunda 7 and Camunda 8](https://miragon.github.io/bpmn-cli/camunda). ## bpmnlint A project's bpmnlint configuration (`.bpmnlintrc`) runs in `bpmn validate` and in every write: the -agent sees the findings its change added and fixes them in the same batch, instead of in CI. Where -it runs `@miragon/rules/element-id-naming`, the ids bpmn-cli generates follow its prefixes and case +agent sees the findings its change added and fixes them in the same batch, instead of in CI. The +ids bpmn-cli generates follow `@miragon/rules/element-id-naming`: the prefixes and case the +`.bpmnlintrc` declares, else the rule's defaults (`userTask_checkInvoice`, +`flow_checkInvoiceToBookInvoice`), so they pass the rule out of the box (`bpmn show --conventions` lists them). Details: [bpmnlint](https://miragon.github.io/bpmn-cli/bpmnlint). diff --git a/docs/.vitepress/theme/LandingDemo.vue b/docs/.vitepress/theme/LandingDemo.vue index e4891776..7c12eefe 100644 --- a/docs/.vitepress/theme/LandingDemo.vue +++ b/docs/.vitepress/theme/LandingDemo.vue @@ -2,7 +2,7 @@ import { onBeforeUnmount, onMounted, ref } from "vue" import { withBase } from "vitepress" -const command = 'bpmn add order.bpmn userTask "Approve invoice" --after Activity_CheckInvoice' +const command = 'bpmn add order.bpmn userTask "Approve invoice" --after userTask_checkInvoice' // The first paint (and reduced motion) shows the end state, so the demo never opens on an empty terminal. const typedChars = ref(command.length) @@ -71,8 +71,8 @@ onBeforeUnmount(() => clearTimeout(timer))

$ {{ command.slice(0, typedChars) }}

-

created userTask Activity_ApproveInvoice "Approve invoice" - after Activity_CheckInvoice

-

note: inserted between Activity_CheckInvoice and Event_Done

+

created userTask userTask_approveInvoice "Approve invoice" - after userTask_checkInvoice

+

note: inserted between userTask_checkInvoice and endEvent_done

written: order.bpmn

diff --git a/docs/architecture.md b/docs/architecture.md index 6bd465f8..994f5ecf 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -65,7 +65,7 @@ file adapters only write or rasterise what it drew. ## The journey of `bpmn add` ```sh -bpmn add order.bpmn userTask "Check invoice" --after Event_OrderReceived +bpmn add order.bpmn userTask "Check invoice" --after startEvent_orderReceived ``` ```mermaid @@ -120,7 +120,7 @@ export class AddElementCommand extends MutationCommand { op: 'add', kind, // 'userTask' name, // 'Check invoice' - after: options['after'], // 'Event_OrderReceived' + after: options['after'], // 'startEvent_orderReceived' // … } await this.mutate(file, [command], opts) @@ -256,7 +256,7 @@ private dispatch(command: EditCommand): ChangeSet { ### 6. The model changes `ElementCreation` makes the new element in the bpmn-moddle tree of the `Doc`, places it -after `Event_OrderReceived` (which creates the sequence flow), and records everything it did in +after `startEvent_orderReceived` (which creates the sequence flow), and records everything it did in a `ChangeSet`. ```ts @@ -282,7 +282,7 @@ export class ElementCreation { trigger: Trigger | undefined, cs: ChangeSet, ): El { - // 'Activity_CheckInvoice' + // 'userTask_checkInvoice' const id = this.edit.allocator.explicitOr(command.id, …) const el = this.edit.doc.create(def.type, { id, name: command.name }) cs.create({ id, kind: def.kind, name: command.name }) @@ -340,8 +340,8 @@ Back in `MutationCommand`, the result becomes a report (`MutationReportBuilder`) CLI and `applyToXml()` describe a change in the same words. ```txt -created userTask Activity_CheckInvoice "Check invoice" - after Event_OrderReceived -created sequenceFlow Flow_OrderReceivedToCheckInvoice - Event_OrderReceived -> Activity_CheckInvoice +created userTask userTask_checkInvoice "Check invoice" - after startEvent_orderReceived +created sequenceFlow flow_orderReceivedToCheckInvoice - startEvent_orderReceived -> userTask_checkInvoice layout: ok - full (engine-owned diagram: redrawn) written: order.bpmn ``` diff --git a/docs/bpmnlint.md b/docs/bpmnlint.md index 5d628310..3034bf89 100644 --- a/docs/bpmnlint.md +++ b/docs/bpmnlint.md @@ -79,11 +79,11 @@ An error reads `[bpmnlint error] ` and fails the check (exit 2). A warning $ bpmn validate models/order.bpmn [bpmnlint error] local/external-task-topic serviceTask_bookOrder: External task has no topic W_C7_DEPLOY_EXTERNAL_TOPIC serviceTask_bookOrder: serviceTask serviceTask_bookOrder is an external task without camunda:topic; the engines refuse the file (`bpmn set serviceTask_bookOrder camunda:topic=`.) -[bpmnlint] @miragon/rules/element-id-naming x4 [Event_OrderReceived]: Element id must match the naming convention (1 of 4; 3 others with 3 other messages, --json lists them) +[bpmnlint] @miragon/rules/element-id-naming x3 [Event_OrderReceived]: Element id must match the naming convention (1 of 3; 2 others with 2 other messages, --json lists them) platform: c7 (modeler:executionPlatform "Camunda Platform") - 1 refused at deploy, 0 runtime, 0 practice finding(s) -validator bpmnlint (.bpmnlintrc in .; project plugins: bpmnlint-plugin-local@1.0.0): 1 error(s), 4 warning(s) in the file +validator bpmnlint (.bpmnlintrc in .; project plugins: bpmnlint-plugin-local@1.0.0): 1 error(s), 3 warning(s) in the file layout: ok -1 error(s), 5 warning(s) +1 error(s), 4 warning(s) ``` ## Writes @@ -97,9 +97,7 @@ created serviceTask serviceTask_bookOrder "Book order" - after Activity_CheckOrd ... warning W_C7_DEPLOY_EXTERNAL_TOPIC serviceTask_bookOrder: serviceTask serviceTask_bookOrder is an external task without camunda:topic; the engines refuse the file (`bpmn set serviceTask_bookOrder camunda:topic=`.) warning [bpmnlint error] local/external-task-topic serviceTask_bookOrder: External task has no topic -resolved: [bpmnlint] @miragon/rules/element-id-naming Flow_CheckOrderToOrderChecked -validator bpmnlint (.bpmnlintrc in .; project plugins: bpmnlint-plugin-local@1.0.0): 1 error(s), 4 warning(s) in the result (1 added, 1 resolved, 4 already in the file) -conventions: serviceTask_bookOrder, flow_checkOrderToBookOrder, flow_bookOrderToOrderChecked follow element-id-naming (.bpmnlintrc in .), not the file's own id style (`bpmn show --conventions`) +validator bpmnlint (.bpmnlintrc in .; project plugins: bpmnlint-plugin-local@1.0.0): 1 error(s), 3 warning(s) in the result (1 added, 0 resolved, 3 already in the file) layout: ok - full (engine-owned diagram: redrawn) written: models/order.bpmn ``` @@ -110,9 +108,83 @@ exits 2 until the first `apply` batch adds them. ## Id conventions -`@miragon/rules/element-id-naming` sets a prefix per BPMN type (`serviceTask_`, `gateway_`, `flow_`) -and the case of the rest. When the `.bpmnlintrc` turns it on, the ids bpmn-cli generates follow it. -Ids the file already has are never renamed; `bpmn set id=` renames one. +`@miragon/rules/element-id-naming` sets a prefix per BPMN type (`serviceTask_`, `gateway_`, +`flow_`) and the case of the rest. The ids bpmn-cli generates always follow it, so they pass the +rule as soon as someone turns it on. + +### Without a .bpmnlintrc + +New ids follow the rule's defaults, in camelCase, whatever ids the file already has: + +| element | prefix | example | +| --- | --- | --- | +| start / end event | `startEvent_`, `endEvent_` | `startEvent_orderReceived` | +| boundary and intermediate event | `event_` | `event_paymentOverdue` | +| task of each type | `task_`, `userTask_`, `serviceTask_`, `sendTask_`, ... | `userTask_checkOrder` | +| call activity, sub-process | `callActivity_`, `subProcess_` | `subProcess_archiveInvoice` | +| every gateway | `gateway_` | `gateway_orderOk` | +| sequence flow, message flow | `flow_`, `messageFlow_` | `flow_checkOrderToShipOrder` | +| pool, lane, collaboration | `participant_`, `lane_`, `collaboration_` | `lane_accounting` | +| data object, data store (reference) | `dataObject_`, `dataStore_` | `dataObject_invoice` | +| text annotation, group, association | `textAnnotation_`, `group_`, `association_` | `group_payment` | + +An event with a trigger takes the plain prefix (`event_paymentOverdue`); the qualified one +(`timerEvent_paymentOverdue`) passes the rule too. + +### With element-id-naming in the .bpmnlintrc + +Its `prefixes`, `case` (`camelCase`, `PascalCase`, `snake_case`, `kebab-case`, `any`) and +`eventDefinitionQualifier` decide. The configured prefixes replace the defaults per type; `false` +switches a type off. + +```json +{ + "extends": ["bpmnlint:recommended"], + "rules": { + "@miragon/rules/element-id-naming": ["warn", { + "case": "snake_case", + "eventDefinitionQualifier": "required", + "prefixes": { "bpmn:UserTask": "ut_", "bpmn:Lane": false } + }] + } +} +``` + +With it, a user task "Check order" becomes `ut_check_order`, a timer boundary event +`timerEvent_payment_overdue` and the flow between two tasks `flow_check_order_to_ship_order`. A +taken id gets `_2` under snake_case (`ut_check_order_2`), `2` under camelCase and PascalCase +(`userTask_checkOrder2`) and `-2` under kebab-case. Under `any` the body is camelCase. + +### Types the rule does not check + +The rule leaves some types out on purpose, and a `.bpmnlintrc` can switch more off. Their new ids +take bpmn-cli's own prefix: the type's name in lower camelCase, with the first letter of the +declared prefixes (`Message_` next to `Activity_`) and the body in the declared case. + +| type | prefix | why | +| --- | --- | --- | +| process | none: a named process is its name (`orderToCash`), an unnamed one `Process_1` | the process id is the deployment key and the target of call activities, so it names the business process; a new pool's process takes the pool's name; a declared `bpmn:Process` prefix wins | +| definitions | `definitions_` (unnamed: `Definitions_1`) | | +| message, signal, error, escalation | `message_`, `signal_`, `error_`, `escalation_` | root elements, found and named by their name | +| lane set, category, category value, property | `laneSet_`, `category_`, `categoryValue_`, `property_` | | +| data object, data store (the backing element) | `dataObjectItem_`, `dataStoreItem_` | apart from the reference's `dataObject_`, so both get the same name without a suffix | +| event definition and any other type | its name: `timerEventDefinition_` | | + +### Existing ids + +Ids the file already has are never renamed, also where they break the rule: +`bpmn set id=` renames one, and its flows and DI follow. After a `retype` or a +trigger change under declared conventions, `W_ID_PREFIX_STALE` names an id whose prefix no longer +fits, with the `bpmn set` that renames it. A flow whose id names its ends in the form above +(`flow_checkOrderToShipOrder`) is renamed when an edit gives it other ends; an older one +(`Flow_CheckOrderToShipOrder`, `SequenceFlow_1`) keeps its id. + +### Choosing ids yourself + +An explicit id is taken as given: `--id` on `bpmn add`, `"id"` in an `apply` op, `--flow-id` / +`"flowId"` for the flow into a new node and `"joinId"` for the join of a split. + +### Checking `bpmn show --conventions` lists the patterns new ids follow and where each comes from, also for a file that does not exist yet: @@ -121,22 +193,30 @@ for a file that does not exist yet: $ bpmn show models/order.bpmn --conventions conventions of models/order.bpmn element-id-naming (.bpmnlintrc in .): warn, case camelCase, eventDefinitionQualifier optional - _{name:camel} lint task userTask serviceTask scriptTask sendTask receiveTask manualTask businessRuleTask subProcess callActivity startEvent endEvent - subProcess_{name:camel} lint eventSubProcess adHocSubProcess transaction - gateway_{name:camel} lint exclusiveGateway parallelGateway inclusiveGateway complexGateway eventBasedGateway - event_{name:camel} lint intermediateCatchEvent intermediateThrowEvent boundaryEvent - _{name:pascal} file participant lane textAnnotation group collaboration message (not checked) - DataObjectReference_{name:pascal} file dataObject (not checked) - DataStoreReference_{name:pascal} file dataStore (not checked) - Process_{name:pascal} default process (not checked) - flow_{source}To{target} lint sequenceFlow - flow_{source}To{target} lint shape messageFlow (not checked) + _{name:camel} lint task userTask serviceTask scriptTask sendTask receiveTask manualTask businessRuleTask subProcess callActivity startEvent endEvent participant lane dataObject dataStore textAnnotation group collaboration + subProcess_{name:camel} lint eventSubProcess adHocSubProcess transaction + gateway_{name:camel} lint exclusiveGateway parallelGateway inclusiveGateway complexGateway eventBasedGateway + event_{name:camel} lint intermediateCatchEvent intermediateThrowEvent boundaryEvent + {name:camel} bpmn-cli process (not checked; unnamed: Process_1) + message_{name:camel} bpmn-cli message (not checked) + flow_{source}To{target} lint sequenceFlow + messageFlow_{source}To{target} lint messageFlow events with a trigger: (timerStartEvent_, messageEvent_, errorEndEvent_) also accepted, new ids take the plain prefix -sources: lint = element-id-naming; file = the file's ids; default = bpmn-cli's default; lint shape = shaped like the declared prefixes (the file shows none) +sources: lint = element-id-naming; bpmn-cli = bpmn-cli's own prefix for a type element-id-naming does not check ``` -What the rule does not name (pools, lanes, data) follows the file's own id style. After a `retype`, -`W_ID_PREFIX_STALE` names an id whose prefix no longer fits, with the `bpmn set` that renames it. +The sources: `lint` is the `.bpmnlintrc`'s `element-id-naming`, `default` its defaults with nothing +declared (no `.bpmnlintrc`, `--no-lint-config`, the rule off), `bpmn-cli` a type the rule does not +check. `bpmn validate` reports ids that break the rule. +A value new ids cannot follow (a prefix that cannot start an id, an unknown case) gets a +`W_CONFIG_IGNORED` note. + +::: warning Breaking change +Earlier versions learned the id style from the file's own ids (`Activity_CheckOrder`, +`Flow_CheckOrderToShipOrder`). A file without a `.bpmnlintrc` now gets the defaults above even +where its existing ids look different. Library hosts that call `idConventionsFromLint` pass the +`naming` namespace of `@miragon/bpmnlint-plugin-rules` 0.19 or later. +::: ## Project code diff --git a/docs/camunda.md b/docs/camunda.md index f3a32d36..48513f03 100644 --- a/docs/camunda.md +++ b/docs/camunda.md @@ -48,17 +48,17 @@ approves the rest. bpmn new claim.bpmn --name "Claim handling" --target camunda7 bpmn add claim.bpmn start "Claim received" bpmn add claim.bpmn serviceTask "Check coverage" \ - --after Event_ClaimReceived camunda:type=external \ + --after startEvent_claimReceived camunda:type=external \ camunda:topic=check-coverage -bpmn ext add claim.bpmn Activity_CheckCoverage camunda:inputParameter \ +bpmn ext add claim.bpmn serviceTask_checkCoverage camunda:inputParameter \ name=amount --body '${amount}' bpmn add claim.bpmn boundary:error "Not covered" \ - --on Activity_CheckCoverage --error "Not covered" \ + --on serviceTask_checkCoverage --error "Not covered" \ --error-code NOT_COVERED -bpmn add claim.bpmn end "Claim rejected" --after Event_NotCovered +bpmn add claim.bpmn end "Claim rejected" --after event_notCovered bpmn add claim.bpmn userTask "Approve claim" \ - --after Activity_CheckCoverage camunda:candidateGroups=claims -bpmn add claim.bpmn end "Claim settled" --after Activity_ApproveClaim + --after serviceTask_checkCoverage camunda:candidateGroups=claims +bpmn add claim.bpmn end "Claim settled" --after userTask_approveClaim ``` ```console @@ -98,27 +98,27 @@ flow. The process then waits for the payment message. bpmn new invoice.bpmn --name "Invoice approval" --target camunda8 bpmn add invoice.bpmn start "Invoice received" bpmn add invoice.bpmn serviceTask "Check invoice" \ - --after Event_InvoiceReceived -bpmn ext add invoice.bpmn Activity_CheckInvoice zeebe:taskDefinition \ + --after startEvent_invoiceReceived +bpmn ext add invoice.bpmn serviceTask_checkInvoice zeebe:taskDefinition \ type=check-invoice retries=3 -bpmn ext add invoice.bpmn Activity_CheckInvoice zeebe:input \ +bpmn ext add invoice.bpmn serviceTask_checkInvoice zeebe:input \ source==invoice.amount target=amount bpmn add invoice.bpmn exclusiveGateway "Amount over 1000?" \ - --after Activity_CheckInvoice + --after serviceTask_checkInvoice bpmn add invoice.bpmn userTask "Approve invoice" \ - --after Gateway_AmountOver1000 --flow-name yes \ + --after gateway_amountOver1000 --flow-name yes \ --condition '= amount > 1000' -bpmn ext add invoice.bpmn Activity_ApproveInvoice \ +bpmn ext add invoice.bpmn userTask_approveInvoice \ zeebe:assignmentDefinition candidateGroups=finance -bpmn add invoice.bpmn exclusiveGateway --id Gateway_Approved \ - --after Activity_ApproveInvoice -bpmn connect invoice.bpmn Gateway_AmountOver1000 Gateway_Approved \ +bpmn add invoice.bpmn exclusiveGateway --id gateway_approved \ + --after userTask_approveInvoice +bpmn connect invoice.bpmn gateway_amountOver1000 gateway_approved \ --name no --default bpmn add invoice.bpmn receiveTask "Wait for payment" \ - --after Gateway_Approved --message "Payment received" -bpmn ext add invoice.bpmn Activity_WaitForPayment zeebe:subscription \ + --after gateway_approved --message "Payment received" +bpmn ext add invoice.bpmn receiveTask_waitForPayment zeebe:subscription \ correlationKey==invoiceId -bpmn add invoice.bpmn end "Invoice settled" --after Activity_WaitForPayment +bpmn add invoice.bpmn end "Invoice settled" --after receiveTask_waitForPayment ``` ### The FEEL pitfall @@ -127,10 +127,10 @@ A JUEL `${...}` condition deploys on Camunda 7 and is refused by Camunda 8. `val with the fix: ```console -$ bpmn set invoice.bpmn Flow_AmountOver1000ToApproveInvoice 'condition=${amount > 1000}' +$ bpmn set invoice.bpmn flow_amountOver1000ToApproveInvoice 'condition=${amount > 1000}' ... $ bpmn validate invoice.bpmn --strict -W_C8_DEPLOY_EXPRESSION Flow_AmountOver1000ToApproveInvoice: The condition "${amount > 1000}" of sequence flow Flow_AmountOver1000ToApproveInvoice is no FEEL expression; Camunda 8 needs one starting with = and refuses the file (`bpmn set Flow_AmountOver1000ToApproveInvoice 'condition== amount > 1000'` (check the FEEL syntax: and / or, = for equality).) +W_C8_DEPLOY_EXPRESSION flow_amountOver1000ToApproveInvoice: The condition "${amount > 1000}" of sequence flow flow_amountOver1000ToApproveInvoice is no FEEL expression; Camunda 8 needs one starting with = and refuses the file (`bpmn set flow_amountOver1000ToApproveInvoice 'condition== amount > 1000'` (check the FEEL syntax: and / or, = for equality).) platform: c8 (modeler:executionPlatform "Camunda Cloud") - 1 refused at deploy, 0 runtime, 0 practice finding(s) layout: ok valid, 1 warning(s) diff --git a/docs/commands.md b/docs/commands.md index f66c8324..6e0231bd 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -57,7 +57,7 @@ keys are in [Syntax and output](reference.md). bpmn new order.bpmn --name "Order handling" --target camunda8 ``` -`--name` gives `Process_OrderHandling`; without it the id is `Process_1`. `--target camunda8` +`--name` gives `orderHandling`; without it the id is `Process_1`. `--target camunda8` declares the `zeebe:` and `modeler:` namespaces, `--target camunda7` the `camunda:` and `modeler:` namespaces plus the `historyTimeToLive` the engine needs. Use `camunda7` for CIB seven and Operaton too ([Camunda 7 and Camunda 8](camunda.md)). @@ -83,14 +83,14 @@ Creates one element and wires it in. A placement option says where; trailing `ke like `set`. ```sh -bpmn add order.bpmn userTask "Check invoice" --after Event_OrderReceived \ +bpmn add order.bpmn userTask "Check invoice" --after startEvent_orderReceived \ camunda:assignee=kermit -bpmn add order.bpmn boundary:timer "2 days" --on Activity_CheckInvoice \ +bpmn add order.bpmn boundary:timer "2 days" --on userTask_checkInvoice \ --timer PT2D --non-interrupting ``` - Placement: `--after`, `--before`, `--flow`, `--in`, `--on`, `--lane` ([Placement grammar](reference.md#placement-grammar)). -- The id comes from the name (`Activity_CheckInvoice`). `--id` with `--if-absent` makes the add idempotent. +- The id comes from the name (`userTask_checkInvoice`). `--id` with `--if-absent` makes the add idempotent. - Lanes, data and annotations take `--in` only; wire them afterwards with `connect`. - A group takes no placement: `--members ` names what it is drawn around. - The first `participant` wraps the existing process. `--black-box` creates an empty pool and never @@ -99,7 +99,7 @@ bpmn add order.bpmn boundary:timer "2 days" --on Activity_CheckInvoice \ ## `connect` ```sh -bpmn connect order.bpmn Gateway_InvoiceOk Activity_BookInvoice --name yes +bpmn connect order.bpmn gateway_invoiceOk serviceTask_bookInvoice --name yes ``` The endpoints decide the kind: sequence flow, message flow (different pools), association @@ -109,13 +109,13 @@ The endpoints decide the kind: sequence flow, message flow (different pools), as ## `set` ```sh -bpmn set order.bpmn Activity_CheckInvoice name="Check the invoice" \ +bpmn set order.bpmn userTask_checkInvoice name="Check the invoice" \ camunda:asyncBefore=true -bpmn set order.bpmn Activity_CheckInvoice \ +bpmn set order.bpmn userTask_checkInvoice \ 'loop.camunda:collection=${invoices}' ``` -`key=` or `--unset key` removes a property. `id=New_Id` renames and re-points every reference. Parts +`key=` or `--unset key` removes a property. `id=` renames and re-points every reference. Parts without an id take a prefix: `definition.`, `loop.`, `condition.`. In a Camunda 8 file most settings are extension elements: `set` refuses them and the hint names the `ext add` to use. @@ -135,10 +135,10 @@ Removes elements with everything that depends on them, then bridges the gap. Changes the kind within a family and keeps id, name, flows, boundary events and lane. ```sh -bpmn retype order.bpmn Event_X startEvent:message --message OrderReceived +bpmn retype order.bpmn startEvent_orderReceived startEvent:message --message OrderReceived ``` -A kind without trigger keeps the current trigger. To drop it, write `retype Event_X startEvent:none`. +A kind without trigger keeps the current trigger. To drop it, write `retype startEvent_orderReceived startEvent:none`. A sub-process with content becomes a task only with `--force`. ## `move` and `order` @@ -147,9 +147,9 @@ A sub-process with content becomes a task only with `--force`. `add`. `order` sets the order of a node's outgoing flows, a pool's lanes or a collaboration's pools. ```sh -bpmn move order.bpmn Activity_ClarifyInvoice --before Activity_BookInvoice -bpmn order order.bpmn Gateway_InvoiceOk Flow_InvoiceOkToClarifyInvoice \ - Flow_InvoiceOkToBookInvoice +bpmn move order.bpmn userTask_clarifyInvoice --before serviceTask_bookInvoice +bpmn order order.bpmn gateway_invoiceOk flow_invoiceOkToClarifyInvoice \ + flow_invoiceOkToBookInvoice ``` The flow order drives the picture: the default flow (else the first) continues straight, the others @@ -160,11 +160,11 @@ go alternately below and above it. To pin a branch as the continuation, make it Reads and writes vendor extension elements. The sub-command comes first. ```console -$ bpmn ext add order.bpmn Activity_BookInvoice camunda:inputParameter name=amount --body '${order.total}' -$ bpmn ext list order.bpmn Activity_BookInvoice +$ bpmn ext add order.bpmn serviceTask_bookInvoice camunda:inputParameter name=amount --body '${order.total}' +$ bpmn ext list order.bpmn serviceTask_bookInvoice 0: camunda:inputOutput camunda:inputParameter name="amount" body="${order.total}" -$ bpmn ext remove order.bpmn Activity_BookInvoice 'camunda:inputParameter[name=amount]' +$ bpmn ext remove order.bpmn serviceTask_bookInvoice 'camunda:inputParameter[name=amount]' ``` Containers such as `camunda:inputOutput` are created and removed for you. Quote selectors: `[` and @@ -178,7 +178,7 @@ what an op creates. See [Ops JSON](ops.md). ```json [ops.json] [ { "op": "add", "kind": "userTask", "name": "Ship order", - "after": "Activity_BookInvoice", "as": "$ship" }, + "after": "serviceTask_bookInvoice", "as": "$ship" }, { "op": "set", "id": "$ship", "values": { "camunda:assignee": "piggy" } } ] ``` @@ -206,16 +206,16 @@ removes overlaps. ## `metrics` Scores the drawing: `score 0: no layout problems`, or one line per problem with its ids, such as -`crossings [Flow_1, Flow_7]`. Every write prints the same score before and after. +`crossings [flow_invoiceOkToBookInvoice, messageFlow_askCustomerToCustomer]`. Every write prints the same score before and after. ## `render` Draws a diagram as PNG or SVG, without a browser, so you or an agent can look at it. ```console -$ bpmn render order.bpmn --around Gateway_InvoiceOk --depth 1 --problems -o ok.png -wrote ok.png 490x305 px ~191 image tokens scale 1.00 plane Collaboration_OrderHandling -crop: around Gateway_InvoiceOk (depth 1): 4 nodes +$ bpmn render order.bpmn --around gateway_invoiceOk --depth 1 --problems -o ok.png +wrote ok.png 490x305 px ~191 image tokens scale 1.00 plane collaboration_orderHandling +crop: around gateway_invoiceOk (depth 1): 4 nodes problems: none ``` diff --git a/docs/contract.md b/docs/contract.md index 0357f3fb..4c419d88 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -13,8 +13,9 @@ edit models that people also edit by hand. model without coordinates. 2. **The CLI keeps the picture.** Every write ends with *validate -> layout -> atomic write* ([Layout modes](layout.md#layout-modes)). -3. **Ids speak and follow the file.** A new id says what it names, in the file's id style - ([Ids](reference.md#ids)). Existing ids stay, except a flow id that names its ends. +3. **Ids speak and follow the project's conventions.** A new id says what it names, with the + prefixes and case of `element-id-naming` ([Ids](reference.md#ids)). Existing ids stay, except + a flow id that names its ends. 4. **Nothing is half done.** A command or a whole `apply` batch succeeds completely or leaves the file untouched. Errors the file already had do not block (`W_PREEXISTING_ERROR`); new ones do, unless `--force`. The project's [bpmnlint](bpmnlint.md) reports but never blocks a write. diff --git a/docs/getting-started.md b/docs/getting-started.md index a7ce9a6d..0e201a67 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -41,17 +41,17 @@ Check it with `bpmn --version`. ```sh bpmn new order.bpmn --name "Order handling" bpmn add order.bpmn start "Order received" -bpmn add order.bpmn userTask "Check invoice" --after Event_OrderReceived -bpmn add order.bpmn end "Done" --after Activity_CheckInvoice +bpmn add order.bpmn userTask "Check invoice" --after startEvent_orderReceived +bpmn add order.bpmn end "Done" --after userTask_checkInvoice ``` Each command prints the ids it created for the next one to use. The last one prints: ```txt -created endEvent Event_Done "Done" - after Activity_CheckInvoice -created sequenceFlow Flow_CheckInvoiceToDone - Activity_CheckInvoice -> Event_Done +created endEvent endEvent_done "Done" - after userTask_checkInvoice +created sequenceFlow flow_checkInvoiceToDone - userTask_checkInvoice -> endEvent_done ... -resolved: W_NO_END Process_OrderHandling, W_DEAD_END Activity_CheckInvoice +resolved: W_NO_END orderHandling, W_DEAD_END userTask_checkInvoice ... written: order.bpmn ``` @@ -67,10 +67,10 @@ bpmn show order.bpmn ``` ```txt -process Process_OrderHandling "Order handling" executable - startEvent Event_OrderReceived "Order received" -> Activity_CheckInvoice (Flow_OrderReceivedToCheckInvoice) - userTask Activity_CheckInvoice "Check invoice" -> Event_Done (Flow_CheckInvoiceToDone) - endEvent Event_Done "Done" +process orderHandling "Order handling" executable + startEvent startEvent_orderReceived "Order received" -> userTask_checkInvoice (flow_orderReceivedToCheckInvoice) + userTask userTask_checkInvoice "Check invoice" -> endEvent_done (flow_checkInvoiceToDone) + endEvent endEvent_done "Done" problems: none ``` diff --git a/docs/layout.md b/docs/layout.md index 34527241..d863b4c9 100644 --- a/docs/layout.md +++ b/docs/layout.md @@ -25,7 +25,7 @@ incremental update fails, `auto` falls back to a full redraw (`W_LAYOUT_INCREMEN
The order process with the customer pool on top and a new task Approve invoice placed between Check invoice and the gateway; the shapes that made room have blue halos -
add ... --after Activity_CheckInvoice: incremental, the drawing is kept and room is made (blue: moved)
+
add ... --after userTask_checkInvoice: incremental, the drawing is kept and room is made (blue: moved)
@@ -74,20 +74,20 @@ flow crosses the yes branch: ```console $ bpmn show order.bpmn --layout -diagram BPMNPlane_Collaboration_OrderHandling (Collaboration_OrderHandling) +diagram BPMNPlane_collaboration_orderHandling (collaboration_orderHandling) columns: c0..c5 - participant Participant_Customer "Customer" - participant Participant_OrderHandling "Order handling" - row 1: c0 Event_OrderReceived, c1 Activity_CheckInvoice, c2 Gateway_InvoiceOk, c3 Activity_BookInvoice, c4 Gateway_InvoiceOk_join, c5 Event_InvoiceHandled - row 2: c3 Activity_AskCustomer + participant participant_customer "Customer" + participant participant_orderHandling "Order handling" + row 1: c0 startEvent_orderReceived, c1 userTask_checkInvoice, c2 gateway_invoiceOk, c3 serviceTask_bookInvoice, c4 gateway_invoiceOkJoin, c5 endEvent_invoiceHandled + row 2: c3 sendTask_askCustomer layout quality: score 6: crossings 1, labelOnLine 1 - crossings [Flow_InvoiceOkToBookInvoice, Flow_AskCustomerToCustomer] - labelOnLine [Flow_InvoiceOkToBookInvoice, Flow_AskCustomerToCustomer] + crossings [flow_invoiceOkToBookInvoice, messageFlow_askCustomerToCustomer] + labelOnLine [flow_invoiceOkToBookInvoice, messageFlow_askCustomerToCustomer] -$ bpmn place order.bpmn Activity_AskCustomer --row-of Activity_CheckInvoice +$ bpmn place order.bpmn sendTask_askCustomer --row-of userTask_checkInvoice layout: ok - incremental (format operations only: drawing kept) - format place #0: moved Activity_BookInvoice, Activity_AskCustomer; rerouted Flow_BookInvoiceToInvoiceOkJoin, Flow_InvoiceOkToBookInvoice, Flow_AskCustomerToInvoiceOkJoin, Flow_InvoiceOkToAskCustomer, Flow_AskCustomerToCustomer -layout quality: score 6 -> 0; resolved: crossings [Flow_InvoiceOkToBookInvoice, Flow_AskCustomerToCustomer], labelOnLine [Flow_InvoiceOkToBookInvoice, Flow_AskCustomerToCustomer] + format place #0: moved serviceTask_bookInvoice, sendTask_askCustomer; rerouted flow_bookInvoiceToInvoiceOkJoin, flow_invoiceOkToBookInvoice, flow_askCustomerToInvoiceOkJoin, flow_invoiceOkToAskCustomer, messageFlow_askCustomerToCustomer +layout quality: score 6 -> 0; resolved: crossings [flow_invoiceOkToBookInvoice, messageFlow_askCustomerToCustomer], labelOnLine [flow_invoiceOkToBookInvoice, messageFlow_askCustomerToCustomer] written: order.bpmn ``` @@ -98,7 +98,7 @@ written: order.bpmn
After: Ask customer sits on the top row, its message flow rises straight to the Customer pool, Book invoice is on the row below -
After place ... --row-of Activity_CheckInvoice: score 0
+
After place ... --row-of userTask_checkInvoice: score 0
## Layout conventions of the built-in engine diff --git a/docs/library.md b/docs/library.md index 1e9f0b58..18652a96 100644 --- a/docs/library.md +++ b/docs/library.md @@ -27,16 +27,16 @@ const edit = await applyToXml(xml, [ op: 'add', kind: 'userTask', name: 'Check invoice', - after: 'Event_OrderReceived', + after: 'startEvent_orderReceived', }, - { op: 'add', kind: 'end', name: 'Done', after: 'Activity_CheckInvoice' }, + { op: 'add', kind: 'end', name: 'Done', after: 'userTask_checkInvoice' }, ]); if (!edit.unchanged) xml = edit.xml; // and save it edit.result.created.map((c) => c.id); -// ['Event_OrderReceived', 'Activity_CheckInvoice', -// 'Flow_OrderReceivedToCheckInvoice', 'Event_Done', -// 'Flow_CheckInvoiceToDone'] +// ['startEvent_orderReceived', 'userTask_checkInvoice', +// 'flow_orderReceivedToCheckInvoice', 'endEvent_done', +// 'flow_checkInvoiceToDone'] edit.result.layout.mode; // 'full' (no diagram before: drawn from scratch) @@ -49,12 +49,12 @@ await showXml(xml); try { await applyToXml(xml, [ - { op: 'add', kind: 'userTask', name: 'Ship', after: 'Activity_Check' }, + { op: 'add', kind: 'userTask', name: 'Ship', after: 'userTask_check' }, ]); } catch (err) { if (!(err instanceof BpmnError)) throw err; err.code; // 'E_NOT_FOUND' - err.details.candidates; // ['Activity_CheckInvoice'] + err.details.candidates; // ['userTask_checkInvoice'] } ``` @@ -157,9 +157,12 @@ edit.result.warnings.added; ### Declared id conventions -A host whose lint runs `@miragon/rules/element-id-naming` passes the same configuration's -conventions, or its lint reports bpmn-cli's new ids. A table of your own works too -([bpmnlint](bpmnlint.md#id-conventions) has the rules): +Without `conventions` new ids follow the defaults of `@miragon/rules/element-id-naming`. A host +whose lint configures that rule otherwise passes the same configuration's conventions, or its lint +reports bpmn-cli's new ids. `idConventionsFromLint` takes the `naming` namespace of +`@miragon/bpmnlint-plugin-rules` 0.19 or later (`engine.plugin.naming`; earlier plugins lack its +`ElementIdConvention`). A table of your own works too ([bpmnlint](bpmnlint.md#id-conventions) has +the rules): ```ts const automation = await resolveBpmnlintConfig( @@ -171,15 +174,11 @@ const { conventions, notes } = idConventionsFromLint( engine.plugin.naming, { source: 'our rules' }, ); -const named = await applyToXml(xml, ops, { +await applyToXml(xml, ops, { validators: [bpmnlintValidator(engine, automation, { notes })], conventions, }); -named.result.conventions; -// { ids: [...], source: 'our rules' }: -// the new ids that follow the rule, not the file's style - await applyToXml(xml, ops, { conventions: { prefixes: { diff --git a/docs/ops.md b/docs/ops.md index d99041e1..f64b52b1 100644 --- a/docs/ops.md +++ b/docs/ops.md @@ -25,7 +25,7 @@ whenever a change touches more than one element. "op": "add", "kind": "serviceTask", "name": "Book invoice", - "after": "Activity_CheckInvoice", + "after": "userTask_checkInvoice", "as": "$book" }, { @@ -45,11 +45,11 @@ whenever a change touches more than one element. ```console $ bpmn apply order.bpmn book.json --summary -created serviceTask: Activity_BookInvoice -created sequenceFlow: Flow_BookInvoiceToInvoiceHandled -aliases: $book = Activity_BookInvoice -changed: Flow_CheckInvoiceToBookInvoice -renamed: Flow_CheckInvoiceToInvoiceHandled -> Flow_CheckInvoiceToBookInvoice +created serviceTask: serviceTask_bookInvoice +created sequenceFlow: flow_bookInvoiceToInvoiceHandled +aliases: $book = serviceTask_bookInvoice +changed: flow_checkInvoiceToBookInvoice +renamed: flow_checkInvoiceToInvoiceHandled -> flow_checkInvoiceToBookInvoice layout: full, score 0 -> 0 written: order.bpmn ``` @@ -94,7 +94,7 @@ creates (new node -> old successor), so the same batch can give it a condition: "op": "add", "kind": "exclusiveGateway", "name": "Valid?", - "flow": "Flow_CheckToOk", + "flow": "flow_checkToOk", "as": "$valid", "outAs": "$yes" } @@ -118,7 +118,7 @@ splits after the check, adds a reminder timer with its own path and gives both t "ops": [ { "op": "split", - "after": "Activity_CheckInvoice", + "after": "userTask_checkInvoice", "kind": "exclusiveGateway", "name": "Invoice ok?", "as": "$ok", @@ -170,7 +170,7 @@ splits after the check, adds a reminder timer with its own path and gives both t "op": "add", "kind": "endEvent", "name": "Reminder sent", - "in": "Process_OrderHandling", + "in": "orderHandling", "as": "$sent" }, { "op": "connect", "source": "$remind", "target": "$sent" }, diff --git a/docs/public/shots/commands-render-halos.svg b/docs/public/shots/commands-render-halos.svg index 7380bd0b..a7887b6d 100644 --- a/docs/public/shots/commands-render-halos.svg +++ b/docs/public/shots/commands-render-halos.svg @@ -1,38 +1,38 @@ -Order handling -Customer - -Check invoice - - -Book invoice +Order handling +Customer + +Check invoice + + +Book invoice -Ask thecustomer +Ask thecustomer - - + + - - - - + + + + - - - - - - -Order received -Invoice handled -Invoice ok? + + + + + + +Order received +Invoice handled +Invoice ok? -No answer +No answer -Order cancelled -yes -no -Question +Order cancelled +yes +no +Question 1 diff --git a/docs/public/shots/getting-started-order.svg b/docs/public/shots/getting-started-order.svg index 3e944ee8..6eaa455b 100644 --- a/docs/public/shots/getting-started-order.svg +++ b/docs/public/shots/getting-started-order.svg @@ -1,10 +1,10 @@ - -Check invoice - - - -Order received -Done + +Check invoice + + + +Order received +Done diff --git a/docs/public/shots/landing-demo-after.svg b/docs/public/shots/landing-demo-after.svg index 965334e8..209b307c 100644 --- a/docs/public/shots/landing-demo-after.svg +++ b/docs/public/shots/landing-demo-after.svg @@ -1,12 +1,12 @@ - -Check invoice - -Approve invoice - - - -Order received -Done + +Check invoice + +Approve invoice + + + +Order received +Done diff --git a/docs/public/shots/landing-demo-before.svg b/docs/public/shots/landing-demo-before.svg index 79a81494..a636027c 100644 --- a/docs/public/shots/landing-demo-before.svg +++ b/docs/public/shots/landing-demo-before.svg @@ -1,10 +1,10 @@ - -Check invoice - - - -Order received -Done + +Check invoice + + + +Order received +Done diff --git a/docs/public/shots/layout-modes-full.svg b/docs/public/shots/layout-modes-full.svg index 00d4df49..d126f01d 100644 --- a/docs/public/shots/layout-modes-full.svg +++ b/docs/public/shots/layout-modes-full.svg @@ -1,31 +1,31 @@ -Customer -Order handling - -Check invoice - +Customer +Order handling + +Check invoice + -Approve invoice - -Book invoice -Ask customer - +Approve invoice + +Book invoice +Ask customer + - + - - - - - - - - -Order received -Invoice handled -Invoice ok? -yes -no -Question + + + + + + + + +Order received +Invoice handled +Invoice ok? +yes +no +Question diff --git a/docs/public/shots/layout-modes-incremental.svg b/docs/public/shots/layout-modes-incremental.svg index fca5510f..58fa719e 100644 --- a/docs/public/shots/layout-modes-incremental.svg +++ b/docs/public/shots/layout-modes-incremental.svg @@ -1,46 +1,46 @@ -Customer -Order handling - -Check invoice +Customer +Order handling + +Check invoice - + - + -Book invoice +Book invoice -Ask customer +Ask customer - + -Approve invoice +Approve invoice - + - + - + - + - - + + - + - + - -Order received + +Order received -Invoice handled +Invoice handled -Invoice ok? +Invoice ok? -yes +yes -no -Question +no +Question diff --git a/docs/public/shots/layout-place-after.svg b/docs/public/shots/layout-place-after.svg index bc97f5e2..47ae51b6 100644 --- a/docs/public/shots/layout-place-after.svg +++ b/docs/public/shots/layout-place-after.svg @@ -1,26 +1,26 @@ -Customer -Order handling - -Check invoice - - -Book invoice -Ask customer - - - - - - - - - -Order received -Invoice handled -Invoice ok? -yes -no -Question +Customer +Order handling + +Check invoice + + +Book invoice +Ask customer + + + + + + + + + +Order received +Invoice handled +Invoice ok? +yes +no +Question diff --git a/docs/public/shots/layout-place-before.svg b/docs/public/shots/layout-place-before.svg index 42ec2e54..5a333cb5 100644 --- a/docs/public/shots/layout-place-before.svg +++ b/docs/public/shots/layout-place-before.svg @@ -1,28 +1,28 @@ -Customer -Order handling - -Check invoice - - -Book invoice -Ask customer - - - - - - - - - -Order received -Invoice handled -Invoice ok? -yes -no -Question +Customer +Order handling + +Check invoice + + +Book invoice +Ask customer + + + + + + + + + +Order received +Invoice handled +Invoice ok? +yes +no +Question 1 2 diff --git a/docs/public/shots/ops-batch-result.svg b/docs/public/shots/ops-batch-result.svg index e68bb1a6..81597e45 100644 --- a/docs/public/shots/ops-batch-result.svg +++ b/docs/public/shots/ops-batch-result.svg @@ -1,29 +1,29 @@ - -Check invoice - - -Book invoice inERP -Clarify invoice -Remindcustomer - - - - - - - - - - - - -Order received -Invoice handled -Invoice ok? -Reminder -Reminder sent -yes -no + +Check invoice + + +Book invoice inERP +Clarify invoice +Remindcustomer + + + + + + + + + + + + +Order received +Invoice handled +Invoice ok? +Reminder +Reminder sent +yes +no diff --git a/docs/public/shots/plugin-change.svg b/docs/public/shots/plugin-change.svg index 9c53c554..a0cc29bb 100644 --- a/docs/public/shots/plugin-change.svg +++ b/docs/public/shots/plugin-change.svg @@ -1,24 +1,24 @@ - -Check invoice + +Check invoice -Send reminder +Send reminder - - + + - + - + - - - -Order received + + + +Order received -3 days +3 days -Reminder sent -Done +Reminder sent +Done diff --git a/docs/public/shots/walkthrough-1-first-steps.svg b/docs/public/shots/walkthrough-1-first-steps.svg index 61da7416..9c4894c0 100644 --- a/docs/public/shots/walkthrough-1-first-steps.svg +++ b/docs/public/shots/walkthrough-1-first-steps.svg @@ -1,10 +1,10 @@ - -Check invoice - - - -Order received -Invoice handled + +Check invoice + + + +Order received +Invoice handled diff --git a/docs/public/shots/walkthrough-2-split.svg b/docs/public/shots/walkthrough-2-split.svg index 82e02b1a..da7be781 100644 --- a/docs/public/shots/walkthrough-2-split.svg +++ b/docs/public/shots/walkthrough-2-split.svg @@ -1,35 +1,35 @@ - -Check invoice - + +Check invoice + - + -Book invoice +Book invoice -Ask customer +Ask customer - + - + - + - + - + - - + + - -Order received -Invoice handled + +Order received +Invoice handled -Invoice ok? +Invoice ok? -yes +yes -no +no diff --git a/docs/public/shots/walkthrough-3-pools.svg b/docs/public/shots/walkthrough-3-pools.svg index 2c03bfe4..38bcad88 100644 --- a/docs/public/shots/walkthrough-3-pools.svg +++ b/docs/public/shots/walkthrough-3-pools.svg @@ -1,26 +1,26 @@ -Order handling -Customer - -Check invoice - - -Book invoice -Ask customer - - - - - - - - - -Order received -Invoice handled -Invoice ok? -yes -no -Question +Order handling +Customer + +Check invoice + + +Book invoice +Ask customer + + + + + + + + + +Order received +Invoice handled +Invoice ok? +yes +no +Question diff --git a/docs/reference.md b/docs/reference.md index cb831407..183be4fd 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -11,34 +11,36 @@ from the code. ## Ids Elements are addressed by id, never by name (names may repeat). A new id says what it names and -follows the file's id style: prefix, case and flow form. A file without a style gets the defaults -below. Explicit ids (`--id`, `"id"` in ops JSON) are taken as given. +follows `@miragon/rules/element-id-naming`: the prefixes and case the project's `.bpmnlintrc` +declares, else the rule's defaults below. The ids the file already has do not change that. +Explicit ids (`--id`, `"id"` in ops JSON) are taken as given. | element | id | example | | --- | --- | --- | -| named | prefix + name | `Activity_CheckInvoice` | -| unnamed | prefix + kind and place | `Gateway_AfterArchive`, `Event_TimerOnArchive` | -| flow | its two ends | `Flow_CheckInvoiceToBookInvoice` | -| join of a split | split id + `_join` | `Gateway_InvoiceOk_join` | +| named | prefix + name | `userTask_checkInvoice` | +| unnamed | prefix + kind and place | `gateway_afterArchive`, `event_timerOnArchive` | +| flow | its two ends | `flow_checkInvoiceToBookInvoice` | +| join of a split | split id + `Join` | `gateway_invoiceOkJoin` | +| named process | its name | `orderToCash` (unnamed: `Process_1`) | -In a file with other conventions the same task becomes `serviceTask_checkStock` or -`Task_check_stock`. Hashes and running numbers are never copied. An unknown id (`E_NOT_FOUND`) -comes with the ids that were probably meant. +Hashes and running numbers are never copied. An unknown id (`E_NOT_FOUND`) comes with the ids that +were probably meant. -A flow whose id names its ends is renamed when an edit changes them (`renamed:` in the result). -`--keep-flow-ids` keeps the id and reports `W_FLOW_ID_STALE` with the `bpmn set` that renames it. +A flow whose id names its ends in this form is renamed when an edit changes them (`renamed:` in the +result); an older flow id (`Flow_AToB`) keeps its id. `--keep-flow-ids` keeps the id and reports +`W_FLOW_ID_STALE` with the `bpmn set` that renames it. -Names become ASCII words (`Prüfung buchen` gives `Activity_PruefungBuchen`), at most 40 characters +Names become ASCII words (`Prüfung buchen` gives `userTask_pruefungBuchen`), at most 40 characters of them: a longer name loses its last words, and a single longer word is cut at 40. An id has at most 64 characters, a collision suffix included. An id within that is not cut again. A longer one loses whole words: of the cuts that fit, the one with the most words is taken, then the longest. A word is cut inside only when no whole word is left to give up. Also, a number a cut would leave at the end of the id goes too where it would read -like a collision suffix. A taken id gets `_2`, `_3` and a `W_ID_SUFFIXED` warning. +like a collision suffix. A taken id gets `2`, `3` (`_2` under snake_case, `-2` under kebab-case) +and a `W_ID_SUFFIXED` warning. -Where the project's `.bpmnlintrc` runs `@miragon/rules/element-id-naming`, its prefixes and case -decide instead (`serviceTask_checkInvoice`). `bpmn show --conventions` lists what applies. -Existing ids never change for the rule. Details: [bpmnlint](bpmnlint.md#id-conventions). +`bpmn show --conventions` lists what applies. Existing ids never change for the rule. +How to get ids that match: [bpmnlint](bpmnlint.md#id-conventions). ## Kinds @@ -47,14 +49,14 @@ type (`bpmn:UserTask`). Events take a trigger suffix (`startEvent:message`, `bou | family | kinds (aliases) | id prefix | | --- | --- | --- | -| tasks | `task`, `userTask` (`user`), `serviceTask` (`service`), `scriptTask`, `sendTask`, `receiveTask`, `manualTask`, `businessRuleTask` (`rule`) | `Activity_` | -| sub-processes | `subProcess` (`sub`), `eventSubProcess` (`eventSub`; needs a start event inside: create it as `eventSubProcess:`, or add both in one `bpmn apply`), `adHocSubProcess`, `transaction` | `Activity_` | -| call activity | `callActivity` (`call`) | `Activity_` | -| gateways | `exclusiveGateway` (`xor`), `parallelGateway` (`and`), `inclusiveGateway` (`or`), `complexGateway` (`complex`), `eventBasedGateway` | `Gateway_` | -| events | `startEvent` (`start`), `endEvent` (`end`), `intermediateCatchEvent` (`catch`), `intermediateThrowEvent` (`throw`), `boundaryEvent` (`boundary`, `--on `) | `Event_` | -| pools and lanes | `participant` (`pool`; the first wraps the process, `--black-box` for one without), `lane` | `Participant_`, `Lane_` | -| data | `dataObject` (`data`), `dataStore` (`store`) | `DataObjectReference_`, `DataStoreReference_` | -| artifacts | `textAnnotation` (`note`), `group` (`--members `) | `TextAnnotation_`, `Group_` | +| tasks | `task`, `userTask` (`user`), `serviceTask` (`service`), `scriptTask`, `sendTask`, `receiveTask`, `manualTask`, `businessRuleTask` (`rule`) | the kind: `task_`, `userTask_`, ... | +| sub-processes | `subProcess` (`sub`), `eventSubProcess` (`eventSub`; needs a start event inside: create it as `eventSubProcess:`, or add both in one `bpmn apply`), `adHocSubProcess`, `transaction` | `subProcess_` | +| call activity | `callActivity` (`call`) | `callActivity_` | +| gateways | `exclusiveGateway` (`xor`), `parallelGateway` (`and`), `inclusiveGateway` (`or`), `complexGateway` (`complex`), `eventBasedGateway` | `gateway_` | +| events | `startEvent` (`start`), `endEvent` (`end`), `intermediateCatchEvent` (`catch`), `intermediateThrowEvent` (`throw`), `boundaryEvent` (`boundary`, `--on `) | `startEvent_`, `endEvent_`, else `event_` | +| pools and lanes | `participant` (`pool`; the first wraps the process, `--black-box` for one without), `lane` | `participant_`, `lane_` | +| data | `dataObject` (`data`), `dataStore` (`store`) | `dataObject_`, `dataStore_` | +| artifacts | `textAnnotation` (`note`), `group` (`--members `) | `textAnnotation_`, `group_` | Choreographies are rejected (`E_UNSUPPORTED_KIND`). No Camunda 7 / 8 engine runs a `complexGateway`: the Camunda profiles report it (`W_C7_DEPLOY_COMPLEX_GATEWAY`, @@ -63,7 +65,7 @@ Choreographies are rejected (`E_UNSUPPORTED_KIND`). No Camunda 7 / 8 engine runs ## Triggers The trigger is the kind's suffix, its details an option: `bpmn add order.bpmn boundary:timer -"Late" --on Activity_Archive --timer P2D`. Messages, errors and signals are found by name and +"Late" --on task_archive --timer P2D`. Messages, errors and signals are found by name and created when missing. | trigger | option | @@ -133,11 +135,11 @@ A mutating command prints what it created, changed and removed, then warnings, t file line: ```console -$ bpmn add order.bpmn end "Done" --after Activity_CheckInvoice -created endEvent Event_Done "Done" - after Activity_CheckInvoice -created sequenceFlow Flow_CheckInvoiceToDone - Activity_CheckInvoice -> Event_Done -note: appended after Activity_CheckInvoice -resolved: W_NO_END Process_OrderHandling, W_DEAD_END Activity_CheckInvoice +$ bpmn add order.bpmn end "Done" --after userTask_checkInvoice +created endEvent endEvent_done "Done" - after userTask_checkInvoice +created sequenceFlow flow_checkInvoiceToDone - userTask_checkInvoice -> endEvent_done +note: appended after userTask_checkInvoice +resolved: W_NO_END orderHandling, W_DEAD_END userTask_checkInvoice layout: ok - full (engine-owned diagram: redrawn) layout quality: score 0 -> 0 written: order.bpmn @@ -151,7 +153,7 @@ written: order.bpmn "file": "order.bpmn", "written": true, "unchanged": false, - "created": [{ "id": "Event_Done", "kind": "endEvent", "name": "Done" }], + "created": [{ "id": "endEvent_done", "kind": "endEvent", "name": "Done" }], "warnings": { "added": [], "resolved": [], "preexistingCount": 0 }, "layout": { "status": "ok", "mode": "full" }, "importWarnings": [] diff --git a/docs/shots/commands-render.sh b/docs/shots/commands-render.sh index b9a59ec3..c5996d8a 100644 --- a/docs/shots/commands-render.sh +++ b/docs/shots/commands-render.sh @@ -2,7 +2,7 @@ # The model of "A worked session" (walkthrough.md): an invoice split, two pools, a message flow. cat > ops.json <<'EOF' { "ops": [ - { "op": "split", "after": "Activity_CheckInvoice", "name": "Invoice ok?", + { "op": "split", "after": "userTask_checkInvoice", "name": "Invoice ok?", "branches": [ { "flowName": "yes", "condition": "= ok", "nodes": [{ "kind": "serviceTask", "name": "Book invoice", "as": "$book" }] }, { "flowName": "no", "default": true, "nodes": [{ "kind": "sendTask", "name": "Ask customer", "as": "$ask" }] } ] }, @@ -11,17 +11,17 @@ cat > ops.json <<'EOF' EOF bpmn new order.bpmn --name "Order handling" --target camunda8 bpmn add order.bpmn start "Order received" -bpmn add order.bpmn userTask "Check invoice" --after Event_OrderReceived -bpmn add order.bpmn end "Invoice handled" --after Activity_CheckInvoice +bpmn add order.bpmn userTask "Check invoice" --after startEvent_orderReceived +bpmn add order.bpmn end "Invoice handled" --after userTask_checkInvoice bpmn apply order.bpmn ops.json -bpmn ext add order.bpmn Activity_AskCustomer zeebe:taskDefinition type=ask-customer +bpmn ext add order.bpmn sendTask_askCustomer zeebe:taskDefinition type=ask-customer bpmn add order.bpmn participant "Order handling" bpmn add order.bpmn participant "Customer" --black-box -bpmn connect order.bpmn Activity_AskCustomer Participant_Customer --message Question +bpmn connect order.bpmn sendTask_askCustomer participant_customer --message Question cat > timeout.json <<'EOF2' -[{ "op": "add", "kind": "boundaryEvent:timer", "name": "No answer", "on": "Activity_AskCustomer", "timer": "P3D", "as": "$timeout" }, +[{ "op": "add", "kind": "boundaryEvent:timer", "name": "No answer", "on": "sendTask_askCustomer", "timer": "P3D", "as": "$timeout" }, { "op": "add", "kind": "endEvent", "name": "Order cancelled", "after": "$timeout" }, - { "op": "set", "id": "Activity_AskCustomer", "values": { "name": "Ask the customer" } }] + { "op": "set", "id": "sendTask_askCustomer", "values": { "name": "Ask the customer" } }] EOF2 bpmn apply order.bpmn timeout.json --render commands-render-halos.svg diff --git a/docs/shots/getting-started.sh b/docs/shots/getting-started.sh index 1514399f..a2c1fb10 100644 --- a/docs/shots/getting-started.sh +++ b/docs/shots/getting-started.sh @@ -1,6 +1,6 @@ # getting-started.md and the landing page: the first model of "Getting started". bpmn new order.bpmn --name "Order handling" bpmn add order.bpmn start "Order received" -bpmn add order.bpmn userTask "Check invoice" --after Event_OrderReceived -bpmn add order.bpmn end "Done" --after Activity_CheckInvoice +bpmn add order.bpmn userTask "Check invoice" --after startEvent_orderReceived +bpmn add order.bpmn end "Done" --after userTask_checkInvoice bpmn render order.bpmn -o getting-started-order.svg diff --git a/docs/shots/landing-demo.sh b/docs/shots/landing-demo.sh index 7f19b83c..1a2c3a3b 100644 --- a/docs/shots/landing-demo.sh +++ b/docs/shots/landing-demo.sh @@ -3,10 +3,10 @@ # after picture, so both line up when the demo fades from one to the other. bpmn new order.bpmn --name "Order handling" bpmn add order.bpmn start "Order received" -bpmn add order.bpmn userTask "Check invoice" --after Event_OrderReceived -bpmn add order.bpmn end "Done" --after Activity_CheckInvoice +bpmn add order.bpmn userTask "Check invoice" --after startEvent_orderReceived +bpmn add order.bpmn end "Done" --after userTask_checkInvoice bpmn render order.bpmn -o landing-demo-before.svg -bpmn add order.bpmn userTask "Approve invoice" --after Activity_CheckInvoice +bpmn add order.bpmn userTask "Approve invoice" --after userTask_checkInvoice bpmn render order.bpmn -o landing-demo-after.svg canvas=$(sed -nE 's/.*]*width="([0-9.]+)" height="[0-9.]+" viewBox="[0-9.-]+ [0-9.-]+ ([0-9.]+) .*/\1 \2/p' landing-demo-after.svg) set -- $canvas diff --git a/docs/shots/layout-modes.sh b/docs/shots/layout-modes.sh index 31c8913a..b8f1afd4 100644 --- a/docs/shots/layout-modes.sh +++ b/docs/shots/layout-modes.sh @@ -2,7 +2,7 @@ # The model of "A worked session" (walkthrough.md): an invoice split, two pools, a message flow. cat > ops.json <<'EOF' { "ops": [ - { "op": "split", "after": "Activity_CheckInvoice", "name": "Invoice ok?", + { "op": "split", "after": "userTask_checkInvoice", "name": "Invoice ok?", "branches": [ { "flowName": "yes", "condition": "= ok", "nodes": [{ "kind": "serviceTask", "name": "Book invoice", "as": "$book" }] }, { "flowName": "no", "default": true, "nodes": [{ "kind": "sendTask", "name": "Ask customer", "as": "$ask" }] } ] }, @@ -11,16 +11,16 @@ cat > ops.json <<'EOF' EOF bpmn new order.bpmn --name "Order handling" --target camunda8 bpmn add order.bpmn start "Order received" -bpmn add order.bpmn userTask "Check invoice" --after Event_OrderReceived -bpmn add order.bpmn end "Invoice handled" --after Activity_CheckInvoice +bpmn add order.bpmn userTask "Check invoice" --after startEvent_orderReceived +bpmn add order.bpmn end "Invoice handled" --after userTask_checkInvoice bpmn apply order.bpmn ops.json -bpmn ext add order.bpmn Activity_AskCustomer zeebe:taskDefinition type=ask-customer +bpmn ext add order.bpmn sendTask_askCustomer zeebe:taskDefinition type=ask-customer bpmn add order.bpmn participant "Order handling" bpmn add order.bpmn participant "Customer" --black-box -bpmn connect order.bpmn Activity_AskCustomer Participant_Customer --message Question +bpmn connect order.bpmn sendTask_askCustomer participant_customer --message Question -bpmn order order.bpmn Collaboration_OrderHandling Participant_Customer -bpmn place order.bpmn Activity_AskCustomer --row-of Activity_CheckInvoice +bpmn order order.bpmn collaboration_orderHandling participant_customer +bpmn place order.bpmn sendTask_askCustomer --row-of userTask_checkInvoice cp order.bpmn redrawn.bpmn -bpmn add order.bpmn userTask "Approve invoice" --after Activity_CheckInvoice --render layout-modes-incremental.svg -bpmn add redrawn.bpmn userTask "Approve invoice" --after Activity_CheckInvoice --layout full --render layout-modes-full.svg +bpmn add order.bpmn userTask "Approve invoice" --after userTask_checkInvoice --render layout-modes-incremental.svg +bpmn add redrawn.bpmn userTask "Approve invoice" --after userTask_checkInvoice --layout full --render layout-modes-full.svg diff --git a/docs/shots/layout-place.sh b/docs/shots/layout-place.sh index 40c18609..bfd97308 100644 --- a/docs/shots/layout-place.sh +++ b/docs/shots/layout-place.sh @@ -2,7 +2,7 @@ # The model of "A worked session" (walkthrough.md): an invoice split, two pools, a message flow. cat > ops.json <<'EOF' { "ops": [ - { "op": "split", "after": "Activity_CheckInvoice", "name": "Invoice ok?", + { "op": "split", "after": "userTask_checkInvoice", "name": "Invoice ok?", "branches": [ { "flowName": "yes", "condition": "= ok", "nodes": [{ "kind": "serviceTask", "name": "Book invoice", "as": "$book" }] }, { "flowName": "no", "default": true, "nodes": [{ "kind": "sendTask", "name": "Ask customer", "as": "$ask" }] } ] }, @@ -11,15 +11,15 @@ cat > ops.json <<'EOF' EOF bpmn new order.bpmn --name "Order handling" --target camunda8 bpmn add order.bpmn start "Order received" -bpmn add order.bpmn userTask "Check invoice" --after Event_OrderReceived -bpmn add order.bpmn end "Invoice handled" --after Activity_CheckInvoice +bpmn add order.bpmn userTask "Check invoice" --after startEvent_orderReceived +bpmn add order.bpmn end "Invoice handled" --after userTask_checkInvoice bpmn apply order.bpmn ops.json -bpmn ext add order.bpmn Activity_AskCustomer zeebe:taskDefinition type=ask-customer +bpmn ext add order.bpmn sendTask_askCustomer zeebe:taskDefinition type=ask-customer bpmn add order.bpmn participant "Order handling" bpmn add order.bpmn participant "Customer" --black-box -bpmn connect order.bpmn Activity_AskCustomer Participant_Customer --message Question +bpmn connect order.bpmn sendTask_askCustomer participant_customer --message Question -bpmn order order.bpmn Collaboration_OrderHandling Participant_Customer +bpmn order order.bpmn collaboration_orderHandling participant_customer bpmn render order.bpmn --problems -o layout-place-before.svg -bpmn place order.bpmn Activity_AskCustomer --row-of Activity_CheckInvoice +bpmn place order.bpmn sendTask_askCustomer --row-of userTask_checkInvoice bpmn render order.bpmn -o layout-place-after.svg diff --git a/docs/shots/ops-batch.sh b/docs/shots/ops-batch.sh index cd744d57..d9133482 100644 --- a/docs/shots/ops-batch.sh +++ b/docs/shots/ops-batch.sh @@ -1,8 +1,8 @@ # ops.md: the result of the full example batch. bpmn new order.bpmn --name "Order handling" --target camunda8 bpmn add order.bpmn start "Order received" -bpmn add order.bpmn userTask "Check invoice" --after Event_OrderReceived -bpmn add order.bpmn end "Invoice handled" --after Activity_CheckInvoice +bpmn add order.bpmn userTask "Check invoice" --after startEvent_orderReceived +bpmn add order.bpmn end "Invoice handled" --after userTask_checkInvoice bpmn kinds --json | node -e 'let s="";process.stdin.on("data",(d)=>s+=d).on("end",()=>process.stdout.write(JSON.stringify(JSON.parse(s).opsExample)))' > ops.json bpmn apply order.bpmn ops.json bpmn render order.bpmn -o ops-batch-result.svg diff --git a/docs/shots/plugin.sh b/docs/shots/plugin.sh index 44f93cf1..7eadf6fc 100644 --- a/docs/shots/plugin.sh +++ b/docs/shots/plugin.sh @@ -1,11 +1,11 @@ # claude-code-plugin.md: what the skill looks at after a change, the result with the change marked. bpmn new order.bpmn --name "Order handling" bpmn add order.bpmn start "Order received" -bpmn add order.bpmn userTask "Check invoice" --after Event_OrderReceived -bpmn add order.bpmn end "Done" --after Activity_CheckInvoice +bpmn add order.bpmn userTask "Check invoice" --after startEvent_orderReceived +bpmn add order.bpmn end "Done" --after userTask_checkInvoice cat > ops.json <<'OPS' { "ops": [ - { "op": "add", "kind": "boundaryEvent:timer", "name": "3 days", "on": "Activity_CheckInvoice", "timer": "P3D", "nonInterrupting": true, "as": "$wait" }, + { "op": "add", "kind": "boundaryEvent:timer", "name": "3 days", "on": "userTask_checkInvoice", "timer": "P3D", "nonInterrupting": true, "as": "$wait" }, { "op": "add", "kind": "sendTask", "name": "Send reminder", "after": "$wait", "as": "$remind" }, { "op": "add", "kind": "endEvent", "name": "Reminder sent", "after": "$remind" } ] } diff --git a/docs/shots/walkthrough.sh b/docs/shots/walkthrough.sh index f1fb7970..d8407b31 100644 --- a/docs/shots/walkthrough.sh +++ b/docs/shots/walkthrough.sh @@ -1,12 +1,12 @@ # walkthrough.md: the order process after the first steps, after the split batch, and with the pools. bpmn new order.bpmn --name "Order handling" --target camunda8 bpmn add order.bpmn start "Order received" -bpmn add order.bpmn userTask "Check invoice" --after Event_OrderReceived -bpmn add order.bpmn end "Invoice handled" --after Activity_CheckInvoice +bpmn add order.bpmn userTask "Check invoice" --after startEvent_orderReceived +bpmn add order.bpmn end "Invoice handled" --after userTask_checkInvoice bpmn render order.bpmn -o walkthrough-1-first-steps.svg cat > ops.json <<'OPS' { "ops": [ - { "op": "split", "after": "Activity_CheckInvoice", "name": "Invoice ok?", + { "op": "split", "after": "userTask_checkInvoice", "name": "Invoice ok?", "branches": [ { "flowName": "yes", "condition": "= ok", "nodes": [{ "kind": "serviceTask", "name": "Book invoice", "as": "$book" }] }, { "flowName": "no", "default": true, "nodes": [{ "kind": "sendTask", "name": "Ask customer", "as": "$ask" }] } ] }, @@ -14,8 +14,8 @@ cat > ops.json <<'OPS' ] } OPS bpmn apply order.bpmn ops.json --render walkthrough-2-split.svg -bpmn ext add order.bpmn Activity_AskCustomer zeebe:taskDefinition type=ask-customer --summary +bpmn ext add order.bpmn sendTask_askCustomer zeebe:taskDefinition type=ask-customer --summary bpmn add order.bpmn participant "Order handling" bpmn add order.bpmn participant "Customer" --black-box -bpmn connect order.bpmn Activity_AskCustomer Participant_Customer --message Question +bpmn connect order.bpmn sendTask_askCustomer participant_customer --message Question bpmn render order.bpmn -o walkthrough-3-pools.svg diff --git a/docs/testing.md b/docs/testing.md index 6ccbfe71..c060e8a6 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -159,19 +159,15 @@ gives another trigger, with the flows named after them, a split's join, the join after an explicit split id in another case), and expects no finding (a control without conventions has them); it also checks how `idConventionsFromLint` reads the options and the `W_CONFIG_IGNORED` notes. -`test/domain/validation/conventions-declared.test.ts` checks the style around them (the rest of -a file stable over many writes and learned from its own ids that do not -follow the rule, single words (also under PascalCase), the kind word, glued -suffixes and joins, also after an explicit split id, message flows and -the processes of new pools over many writes and after the file's own -process ids where two share a prefix, flows renamed into the declared -form, `W_ID_PREFIX_STALE` alone and in a batch (also for a type the rule +`test/domain/validation/conventions-declared.test.ts` checks the ids around them (pools, lanes, +data and message flows with their defaults, the processes of new pools, single words (also under +PascalCase), the kind word, glued suffixes and joins, also after an explicit split id, flows +renamed in the current form while older flow ids stay, `W_ID_PREFIX_STALE` alone and in a batch (also for a type the rule switched off, and the id of what the element is now for an id that did not pass the rule), the new id of an element the batch created and of the flows named after it (also in the entries of an op before its retype), an id -freed and then claimed explicitly, the `conventions` -of a result (none in a new file or one that follows the rule), the view of -`show --conventions` with its reasons). +freed and then claimed explicitly, the view of `show --conventions` with its sources and +reasons). `test/adapters/out/files/bpmnlint.test.ts` and `test/e2e/bpmnlint-cli.test.ts` check the lookup, the notes in validate and writes (also the one for a prefix declared for a DI type), `show --conventions` and `new` on the command line (its own errors before @@ -180,7 +176,7 @@ with it off, writes every fixture byte for byte as without one. `test/e2e/pipeline.test.ts` checks that the parts of step 2 work as one pipeline: the in-memory API keeps the text (a no-op is the input string -itself, an insert gets ids in the file's style and changes only what it +itself, an insert gets ids in the id conventions and changes only what it touched, a full redraw keeps every DI start tag, a sticky keeps its text when it follows its node), validators see the text-preserving result, a host validator runs in memory (a write that adds its error is refused, @@ -631,30 +627,17 @@ why) and deserves a synthetic fixture. ## Speaking ids -`tools/speaking-ids.mjs` measures new ids on a corpus in-process (dry runs): -per file a named task, an unnamed gateway and an unnamed boundary event at -the first task with one outgoing flow. It prints the share of new ids that -speak (no Camunda Modeler hash, no number), the flows renamed because their -ids named their old ends, with `--baseline` the share that keeps the prefix -another build gives the same element, and how often two independent edits -of one file (two branches) share a new id (different names, the same name, -unnamed elements). - -``` -npm run build -node tools/speaking-ids.mjs # tools/scenarios -node tools/speaking-ids.mjs --baseline /dist/index.js ~/corpora/hand -``` - -The regression tests: `test/domain/document/ids/speaking-ids.test.ts` and -`test/domain/document/ids/id-style.test.ts` (ids in every file style, renamed flows and -their DI, also after an end got another id earlier in the batch; a flow -form that stays put over many edits, also in snake_case and Pascal_Snake -bodies, ties, flows under an outvoted prefix or scope), -`test/domain/editing/keep-flow-ids.test.ts` (flow ids kept with `--keep-flow-ids`, -`W_FLOW_ID_STALE`, an end renamed or named earlier, `--flow-id` on a -splice, followed hints in files of mixed flow prefixes: -`test/fixtures/mixed-styles/`), `test/domain/batch/batch-aliases.test.ts` (aliases in `apply`, on the command +The regression tests: `test/domain/document/ids/default-ids.test.ts` (new ids follow the declared +conventions, else element-id-naming's defaults, whatever ids the file has; bpmn-cli's prefixes for +the types the rule does not check; the guard that `DEFAULT_ID_CONVENTIONS` equals the plugin's +defaults), `test/domain/document/ids/speaking-ids.test.ts` and +`test/domain/document/ids/id-style.test.ts` (renamed flows and their DI, also after an end got +another id earlier in the batch; a flow id in an older form that keeps its id), +`test/domain/document/ids/id-quality.test.ts` (cuts to 64 characters, collisions, +transliteration), `test/domain/editing/keep-flow-ids.test.ts` (flow ids kept with +`--keep-flow-ids`, `W_FLOW_ID_STALE`, an end renamed or named earlier, `--flow-id` on a splice), +`test/domain/validation/conventions-*.test.ts` (declared conventions and the reader of the +`.bpmnlintrc`), `test/domain/batch/batch-aliases.test.ts` (aliases in `apply`, on the command line too), `test/domain/document/not-found.test.ts` (the candidates of `E_NOT_FOUND`), `test/domain/editing/remove-branch.test.ts` (`remove --with-branch` / `--bridge-all`) and `test/domain/editing/lane-splice.test.ts` (the lane and the geometry of a node added into a diff --git a/docs/validators.md b/docs/validators.md index fca423f2..a0fd98a5 100644 --- a/docs/validators.md +++ b/docs/validators.md @@ -38,7 +38,7 @@ const houseRules: NamedValidator = { let { xml } = await newXml({ processName: 'Order handling' }); ({ xml } = await applyToXml(xml, [ { op: 'add', kind: 'start', name: 'Order received' }, - { op: 'add', kind: 'end', name: 'Done', after: 'Event_OrderReceived' }, + { op: 'add', kind: 'end', name: 'Done', after: 'startEvent_orderReceived' }, ])); try { @@ -46,7 +46,7 @@ try { op: 'add', kind: 'userTask', name: 'Archive', - in: 'Process_OrderHandling', + in: 'orderHandling', }; await applyToXml(xml, [archive], { validators: [houseRules] }); } catch (err) { @@ -58,16 +58,16 @@ try { // house-rules; nothing was written' err.details.errors; // [{ code: 'DEAD_END', - // message: 'Activity_Archive has no outgoing sequence flow', + // message: 'userTask_archive has no outgoing sequence flow', // validator: 'house-rules', severity: 'error', - // element: 'Activity_Archive' }] + // element: 'userTask_archive' }] } const check = { op: 'add', kind: 'userTask', name: 'Check invoice', - after: 'Event_OrderReceived', + after: 'startEvent_orderReceived', }; const edit = await applyToXml(xml, [check], { validators: [houseRules] }); diff --git a/docs/walkthrough.md b/docs/walkthrough.md index ec3c3825..8611a335 100644 --- a/docs/walkthrough.md +++ b/docs/walkthrough.md @@ -9,7 +9,7 @@ the [plugin](./claude-code-plugin.md). A line `...` stands for output left out. ```console $ bpmn new order.bpmn --name "Order handling" --target camunda8 -created process Process_OrderHandling "Order handling" +created process orderHandling "Order handling" ... ``` @@ -20,14 +20,14 @@ Each result names its new ids for the next command. A warning carries its fix. ```console{6,7} $ bpmn add order.bpmn start "Order received" ... -$ bpmn add order.bpmn userTask "Check invoice" --after Event_OrderReceived -created userTask Activity_CheckInvoice "Check invoice" - after Event_OrderReceived +$ bpmn add order.bpmn userTask "Check invoice" --after startEvent_orderReceived +created userTask userTask_checkInvoice "Check invoice" - after startEvent_orderReceived ... -warning W_DEAD_END Activity_CheckInvoice: userTask Activity_CheckInvoice "Check invoice" has no outgoing flow (Continue the flow (`bpmn add "" --after Activity_CheckInvoice`) or end it (`bpmn add endEvent "" --after Activity_CheckInvoice`).) -resolved: W_DEAD_END Event_OrderReceived +warning W_DEAD_END userTask_checkInvoice: userTask userTask_checkInvoice "Check invoice" has no outgoing flow (Continue the flow (`bpmn add "" --after userTask_checkInvoice`) or end it (`bpmn add endEvent "" --after userTask_checkInvoice`).) +resolved: W_DEAD_END startEvent_orderReceived ... -$ bpmn add order.bpmn end "Invoice handled" --after Activity_CheckInvoice +$ bpmn add order.bpmn end "Invoice handled" --after userTask_checkInvoice ... ``` @@ -47,7 +47,7 @@ ops. "ops": [ { "op": "split", - "after": "Activity_CheckInvoice", + "after": "userTask_checkInvoice", "name": "Invoice ok?", "branches": [ { @@ -83,10 +83,10 @@ ops. ```console{4,5} $ bpmn apply order.bpmn ops.json -created exclusiveGateway Gateway_InvoiceOk "Invoice ok?" - between Activity_CheckInvoice and Event_InvoiceHandled +created exclusiveGateway gateway_invoiceOk "Invoice ok?" - between userTask_checkInvoice and endEvent_invoiceHandled ... -aliases: $book = Activity_BookInvoice, $ask = Activity_AskCustomer -warning W_C8_DEPLOY_IMPLEMENTATION Activity_AskCustomer: sendTask Activity_AskCustomer has no zeebe:taskDefinition (nor a zeebe:publishMessage): Camunda 8 needs the job type a worker subscribes to and refuses the file (Give it a job type: `bpmn ext add Activity_AskCustomer zeebe:taskDefinition type=`.) +aliases: $book = serviceTask_bookInvoice, $ask = sendTask_askCustomer +warning W_C8_DEPLOY_IMPLEMENTATION sendTask_askCustomer: sendTask sendTask_askCustomer has no zeebe:taskDefinition (nor a zeebe:publishMessage): Camunda 8 needs the job type a worker subscribes to and refuses the file (Give it a job type: `bpmn ext add sendTask_askCustomer zeebe:taskDefinition type=`.) ... ``` @@ -100,8 +100,8 @@ warning W_C8_DEPLOY_IMPLEMENTATION Activity_AskCustomer: sendTask Activity_AskCu The `ext add` from the hint gives the send task its job type. `--summary` shortens the result. ```console -$ bpmn ext add order.bpmn Activity_AskCustomer zeebe:taskDefinition type=ask-customer --summary -changed: Activity_AskCustomer +$ bpmn ext add order.bpmn sendTask_askCustomer zeebe:taskDefinition type=ask-customer --summary +changed: sendTask_askCustomer warnings: 0 added, 1 resolved, 0 already in the file layout: full, score 0 -> 0 written: order.bpmn @@ -116,15 +116,15 @@ Only a normal pool wraps the existing process. A `--black-box` pool added first ```console $ bpmn add order.bpmn participant "Order handling" -created collaboration Collaboration_OrderHandling -created participant Participant_OrderHandling "Order handling" - wraps process Process_OrderHandling +created collaboration collaboration_orderHandling +created participant participant_orderHandling "Order handling" - wraps process orderHandling ... $ bpmn add order.bpmn participant "Customer" --black-box -created participant Participant_Customer "Customer" - black box (no process) +created participant participant_customer "Customer" - black box (no process) ... -$ bpmn connect order.bpmn Activity_AskCustomer Participant_Customer --message Question -created message Message_Question "Question" - root element -created messageFlow Flow_AskCustomerToCustomer - Activity_AskCustomer -> Participant_Customer +$ bpmn connect order.bpmn sendTask_askCustomer participant_customer --message Question +created message message_question "Question" - root element +created messageFlow messageFlow_askCustomerToCustomer - sendTask_askCustomer -> participant_customer ... ``` @@ -140,18 +140,18 @@ created messageFlow Flow_AskCustomerToCustomer - Activity_AskCustomer -> Partici ```console $ bpmn show order.bpmn ... -collaboration Collaboration_OrderHandling - participant Participant_OrderHandling "Order handling" = Process_OrderHandling - participant Participant_Customer "Customer" (black box) - messageFlow Flow_AskCustomerToCustomer: Activity_AskCustomer "Ask customer" -> Participant_Customer "Customer" [message Question] -process Process_OrderHandling "Order handling" executable in Participant_OrderHandling - startEvent Event_OrderReceived "Order received" -> Activity_CheckInvoice (Flow_OrderReceivedToCheckInvoice) - userTask Activity_CheckInvoice "Check invoice" [ext: zeebe:userTask] -> Gateway_InvoiceOk (Flow_CheckInvoiceToInvoiceOk) - exclusiveGateway Gateway_InvoiceOk "Invoice ok?" -> Activity_BookInvoice (Flow_InvoiceOkToBookInvoice "yes" if = ok), Activity_AskCustomer (Flow_InvoiceOkToAskCustomer "no" default) - serviceTask Activity_BookInvoice "Book invoice" [job=book-invoice, ext: zeebe:taskDefinition] -> Gateway_InvoiceOk_join (Flow_BookInvoiceToInvoiceOkJoin) - exclusiveGateway Gateway_InvoiceOk_join -> Event_InvoiceHandled (Flow_InvoiceOkJoinToInvoiceHandled) - endEvent Event_InvoiceHandled "Invoice handled" - sendTask Activity_AskCustomer "Ask customer" [job=ask-customer, ext: zeebe:taskDefinition] -> Gateway_InvoiceOk_join (Flow_AskCustomerToInvoiceOkJoin) +collaboration collaboration_orderHandling + participant participant_orderHandling "Order handling" = orderHandling + participant participant_customer "Customer" (black box) + messageFlow messageFlow_askCustomerToCustomer: sendTask_askCustomer "Ask customer" -> participant_customer "Customer" [message Question] +process orderHandling "Order handling" executable in participant_orderHandling + startEvent startEvent_orderReceived "Order received" -> userTask_checkInvoice (flow_orderReceivedToCheckInvoice) + userTask userTask_checkInvoice "Check invoice" [ext: zeebe:userTask] -> gateway_invoiceOk (flow_checkInvoiceToInvoiceOk) + exclusiveGateway gateway_invoiceOk "Invoice ok?" -> serviceTask_bookInvoice (flow_invoiceOkToBookInvoice "yes" if = ok), sendTask_askCustomer (flow_invoiceOkToAskCustomer "no" default) + serviceTask serviceTask_bookInvoice "Book invoice" [job=book-invoice, ext: zeebe:taskDefinition] -> gateway_invoiceOkJoin (flow_bookInvoiceToInvoiceOkJoin) + exclusiveGateway gateway_invoiceOkJoin -> endEvent_invoiceHandled (flow_invoiceOkJoinToInvoiceHandled) + endEvent endEvent_invoiceHandled "Invoice handled" + sendTask sendTask_askCustomer "Ask customer" [job=ask-customer, ext: zeebe:taskDefinition] -> gateway_invoiceOkJoin (flow_askCustomerToInvoiceOkJoin) ... $ bpmn validate order.bpmn diff --git a/package-lock.json b/package-lock.json index c9811b5e..f6201965 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.8.0", "license": "MIT", "dependencies": { - "@miragon/bpmnlint-plugin-rules": "0.14.0", + "@miragon/bpmnlint-plugin-rules": "0.19.0", "@resvg/resvg-wasm": "2.6.2", "bpmn-moddle": "^10.2.0", "bpmnlint": "11.15.0", @@ -790,9 +790,9 @@ } }, "node_modules/@miragon/bpmnlint-plugin-rules": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/@miragon/bpmnlint-plugin-rules/-/bpmnlint-plugin-rules-0.14.0.tgz", - "integrity": "sha512-uYIlg+EjeoEJK3K8jjutuG4g857Qr8lQWOj8SI8SioZHfkLNLVd2V5yLx1SO7pDuZLPNAJVi3eK7Noy7ym8cEQ==", + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@miragon/bpmnlint-plugin-rules/-/bpmnlint-plugin-rules-0.19.0.tgz", + "integrity": "sha512-jjCIu2qL3gDfg82HzVKZEoCfKZVWwaLNHyRP5JVwD6nq5VgHG0ZOcXFwD6ROoueRqjn3NI2nbTViK4mv7PLZeQ==", "license": "MIT", "workspaces": [ "docs" diff --git a/package.json b/package.json index 314cc117..edf267ae 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "prepublishOnly": "npm run build" }, "dependencies": { - "@miragon/bpmnlint-plugin-rules": "0.14.0", + "@miragon/bpmnlint-plugin-rules": "0.19.0", "@resvg/resvg-wasm": "2.6.2", "bpmn-moddle": "^10.2.0", "bpmnlint": "11.15.0", diff --git a/plugins/bpmn-cli/README.md b/plugins/bpmn-cli/README.md index 2c58a8e9..8e2aed6d 100644 --- a/plugins/bpmn-cli/README.md +++ b/plugins/bpmn-cli/README.md @@ -34,9 +34,9 @@ version of this plugin (Node 22.12+). It pre-approves only those two commands. piped in with `printf '%s' '' | bpmn apply -`, which runs under that pre-approval (a heredoc holding JSON makes Claude Code ask first). - **Standing rules** - never write or patch `.bpmn` XML or BPMNDI; address elements by id only; - give every created element an explicit speaking id (`Activity_CheckInvoice`); aliases for back - references inside a batch; one `bpmn apply` batch per change; keep the user's drawing - (`--relayout` only on request). + give every created element an explicit speaking id in the conventions `bpmn show --conventions` + lists (`userTask_checkInvoice`); aliases for back references inside a batch; one `bpmn apply` + batch per change; keep the user's drawing (`--relayout` only on request). - **The loop** - orient with `show`, `show --around`, `show --context`, `find`; change in one batch; read the result (layout mode, placed / moved, added layout problems, the warnings the change added); look at a change that drew something (`bpmn render --problems -o diff --git a/plugins/bpmn-cli/evals/README.md b/plugins/bpmn-cli/evals/README.md index c1623f36..edcd60bc 100644 --- a/plugins/bpmn-cli/evals/README.md +++ b/plugins/bpmn-cli/evals/README.md @@ -8,7 +8,7 @@ report shows what the skill adds. | case | request | what the graders check | | --- | --- | --- | | `build-order-to-cash` | an order-to-cash model from scratch: lanes Vertrieb / Lager / Buchhaltung, a customer pool, message flows | file created; lane set and the three lanes; two pools and four message flows; a diagram; speaking ids; built with `bpmn new` / `apply` / `add`; validated with the CLI and clean (`valid, 0 warning(s)`); no hand-written XML (no Write / Edit of a .bpmn, no XML in a shell command); a judge on the reply | -| `edit-hand-drawn-model` | insert a manager approval and a 3-day non-interrupting reminder into a model drawn in Camunda Modeler (scaffold) | the approval (a user task, found by its name, whatever its id) sits on the "yes" flow before "Order goods", directly or after its decision gateway; the timer boundary event on it and its path; start, check and gateway did not move, "Order goods" kept its row; new ids speak; no task with two incoming flows (implicit join); validated with the CLI; no hand-written XML; a judge on the reply | +| `edit-hand-drawn-model` | insert a manager approval and a 3-day non-interrupting reminder into a model drawn in Camunda Modeler (scaffold) | the approval (a user task, found by its name, whatever its id) sits on the "yes" flow before "Order goods", directly or after its decision gateway; the timer boundary event on it and its path; start, check and gateway did not move, "Order goods" kept its row; new ids follow element-id-naming's defaults (`userTask_approvePurchase`, no hash or number); no task with two incoming flows (implicit join); validated with the CLI; no hand-written XML; a judge on the reply | | `camunda7-external-task` | Camunda 7: external task, input mapping, error mapping, error boundary path; must pass `bpmn validate --strict` (scaffold) | `camunda:type` / `topic`; `camunda:inputParameter`; `camunda:errorEventDefinition` + `bpmn:error` code; boundary path; `validate --strict` ran and printed `valid, 0 warning(s)`; no hand-written XML | | `format-happy-path` | colour the happy path green and move the rejection branch below the main line (scaffold) | the path's shapes and flows are green (the bpmn-js green the CLI writes, or another common green); the rejection branch is not and lies below; the main line did not move; flows and nodes unchanged; validated with the CLI; no hand-written XML | | `tidy-after-edit` | add a step to a hand-drawn model whose drawing has a gateway label on a task and the rejection branch crossing a reminder path, and clean up the drawing (scaffold) | the new task sits between "Return invoice to supplier" and "Invoice returned"; the gateway label is off the tasks and lines and the rejection branch no longer crosses the reminder path (the two problems `bpmn metrics` reports on the fixture); the main line did not move; nodes and flows otherwise unchanged; validated with the CLI; no hand-written XML; a rendered PNG was read (the look step; an indicator like `skill-used`) | diff --git a/plugins/bpmn-cli/evals/camunda7-external-task/graders/error-boundary-path.md b/plugins/bpmn-cli/evals/camunda7-external-task/graders/error-boundary-path.md index f8f49231..87a06862 100644 --- a/plugins/bpmn-cli/evals/camunda7-external-task/graders/error-boundary-path.md +++ b/plugins/bpmn-cli/evals/camunda7-external-task/graders/error-boundary-path.md @@ -1,5 +1,5 @@ --- type: regex -pattern: '^(?=[\s\S]*]*attachedToRef="Activity_ChargeCreditCard"[^>]*>[\s\S]*?]*name="Inform customer")(?=[\s\S]*]*name="Payment failed")' +pattern: '^(?=[\s\S]*]*attachedToRef="serviceTask_chargeCreditCard"[^>]*>[\s\S]*?]*name="Inform customer")(?=[\s\S]*]*name="Payment failed")' target: { source: file, path: payment.bpmn } --- diff --git a/plugins/bpmn-cli/evals/camunda7-external-task/graders/external-task.md b/plugins/bpmn-cli/evals/camunda7-external-task/graders/external-task.md index b6568778..1aa72028 100644 --- a/plugins/bpmn-cli/evals/camunda7-external-task/graders/external-task.md +++ b/plugins/bpmn-cli/evals/camunda7-external-task/graders/external-task.md @@ -1,5 +1,5 @@ --- type: regex -pattern: ']*\bid="Activity_ChargeCreditCard")(?=[^>]*camunda:type="external")(?=[^>]*camunda:topic="charge-credit-card")' +pattern: ']*\bid="serviceTask_chargeCreditCard")(?=[^>]*camunda:type="external")(?=[^>]*camunda:topic="charge-credit-card")' target: { source: file, path: payment.bpmn } --- diff --git a/plugins/bpmn-cli/evals/camunda7-external-task/scaffold.sh b/plugins/bpmn-cli/evals/camunda7-external-task/scaffold.sh index 63f10f96..e202d12e 100755 --- a/plugins/bpmn-cli/evals/camunda7-external-task/scaffold.sh +++ b/plugins/bpmn-cli/evals/camunda7-external-task/scaffold.sh @@ -5,42 +5,42 @@ set -euo pipefail cat > payment.bpmn <<'BPMN_FIXTURE' - - - Flow_PaymentRequestedToChargeCreditCard + + + flow_paymentRequestedToChargeCreditCard - - Flow_PaymentRequestedToChargeCreditCard - Flow_ChargeCreditCardToPaymentReceived + + flow_paymentRequestedToChargeCreditCard + flow_chargeCreditCardToPaymentReceived - - Flow_ChargeCreditCardToPaymentReceived + + flow_chargeCreditCardToPaymentReceived - - + + - - - + + + - + - + - + - + diff --git a/plugins/bpmn-cli/evals/edit-hand-drawn-model/graders/no-implicit-join.md b/plugins/bpmn-cli/evals/edit-hand-drawn-model/graders/no-implicit-join.md index 87a24000..6cfa2656 100644 --- a/plugins/bpmn-cli/evals/edit-hand-drawn-model/graders/no-implicit-join.md +++ b/plugins/bpmn-cli/evals/edit-hand-drawn-model/graders/no-implicit-join.md @@ -1,6 +1,6 @@ --- type: regex -pattern: ']*\btargetRef="(Activity_[^"]+)"[\s\S]*]*\btargetRef="\1"' +pattern: ']*\btargetRef="((?:Activity|task|userTask|sendTask|serviceTask|receiveTask|manualTask|scriptTask|businessRuleTask|callActivity|subProcess)_[^"]+)"[\s\S]*]*\btargetRef="\1"' match: 'not_contains' target: { source: file, path: purchase-request.bpmn } --- diff --git a/plugins/bpmn-cli/evals/edit-hand-drawn-model/graders/speaking-new-ids.md b/plugins/bpmn-cli/evals/edit-hand-drawn-model/graders/speaking-new-ids.md index 4bb0de1f..fe01829a 100644 --- a/plugins/bpmn-cli/evals/edit-hand-drawn-model/graders/speaking-new-ids.md +++ b/plugins/bpmn-cli/evals/edit-hand-drawn-model/graders/speaking-new-ids.md @@ -1,6 +1,6 @@ --- type: regex -pattern: '\bid="(?!Definitions_|BPMNDiagram_|BPMNPlane_|(?:StartEvent_1|Process_0c4m2wd|Activity_0k3x9qa|Gateway_1hd8x2m|Activity_1v0w8zr|Event_0q2mb7c|Activity_0f3j5ny|Event_1n8ld0a|Flow_0b8wq1x|Flow_1j4kz7p|Flow_0yq6cfd|Flow_1r2x9bv|Flow_0m7sd3e|Flow_1c5ag8h)")[A-Za-z]+_(?:\d+|[0-9][0-9a-z]{6})"' +pattern: '\bid="(?!Definitions_|BPMNDiagram_|BPMNPlane_|(?:StartEvent_1|Process_0c4m2wd|Activity_0k3x9qa|Gateway_1hd8x2m|Activity_1v0w8zr|Event_0q2mb7c|Activity_0f3j5ny|Event_1n8ld0a|Flow_0b8wq1x|Flow_1j4kz7p|Flow_0yq6cfd|Flow_1r2x9bv|Flow_0m7sd3e|Flow_1c5ag8h)(?:_di)?"|[a-z][A-Za-z]*_[a-z][A-Za-z0-9]*(?:_di)?")[^"]*"' match: 'not_contains' target: { source: file, path: purchase-request.bpmn } --- diff --git a/plugins/bpmn-cli/evals/format-happy-path/graders/happy-path-green.md b/plugins/bpmn-cli/evals/format-happy-path/graders/happy-path-green.md index 210dd2ca..407ace97 100644 --- a/plugins/bpmn-cli/evals/format-happy-path/graders/happy-path-green.md +++ b/plugins/bpmn-cli/evals/format-happy-path/graders/happy-path-green.md @@ -1,6 +1,6 @@ --- type: regex -pattern: '^(?=[\s\S]*]*bpmnElement="Event_ClaimReceived")(?=[^>]*(?:#(?:c8e6c9|205022|e8f5e9|a5d6a7|81c784|66bb6a|4caf50|43a047|388e3c|2e7d32|1b5e20|b9f6ca|69f0ae|00e676|00c853|008000|00ff00|0f0|080|32cd32|228b22|006400|2e8b57|3cb371|90ee90|98fb98|00ff7f|7cfc00|7fff00|9acd32|6b8e23|8fbc8f|adff2f)\b|"(?:[a-z]*green|lime)")))(?=[\s\S]*]*bpmnElement="Activity_AssessClaim")(?=[^>]*(?:#(?:c8e6c9|205022|e8f5e9|a5d6a7|81c784|66bb6a|4caf50|43a047|388e3c|2e7d32|1b5e20|b9f6ca|69f0ae|00e676|00c853|008000|00ff00|0f0|080|32cd32|228b22|006400|2e8b57|3cb371|90ee90|98fb98|00ff7f|7cfc00|7fff00|9acd32|6b8e23|8fbc8f|adff2f)\b|"(?:[a-z]*green|lime)")))(?=[\s\S]*]*bpmnElement="Activity_PayOutClaim")(?=[^>]*(?:#(?:c8e6c9|205022|e8f5e9|a5d6a7|81c784|66bb6a|4caf50|43a047|388e3c|2e7d32|1b5e20|b9f6ca|69f0ae|00e676|00c853|008000|00ff00|0f0|080|32cd32|228b22|006400|2e8b57|3cb371|90ee90|98fb98|00ff7f|7cfc00|7fff00|9acd32|6b8e23|8fbc8f|adff2f)\b|"(?:[a-z]*green|lime)")))(?=[\s\S]*]*bpmnElement="Event_ClaimPaid")(?=[^>]*(?:#(?:c8e6c9|205022|e8f5e9|a5d6a7|81c784|66bb6a|4caf50|43a047|388e3c|2e7d32|1b5e20|b9f6ca|69f0ae|00e676|00c853|008000|00ff00|0f0|080|32cd32|228b22|006400|2e8b57|3cb371|90ee90|98fb98|00ff7f|7cfc00|7fff00|9acd32|6b8e23|8fbc8f|adff2f)\b|"(?:[a-z]*green|lime)")))(?=[\s\S]*]*bpmnElement="Flow_AssessClaimToClaimCovered")(?=[^>]*(?:#(?:c8e6c9|205022|e8f5e9|a5d6a7|81c784|66bb6a|4caf50|43a047|388e3c|2e7d32|1b5e20|b9f6ca|69f0ae|00e676|00c853|008000|00ff00|0f0|080|32cd32|228b22|006400|2e8b57|3cb371|90ee90|98fb98|00ff7f|7cfc00|7fff00|9acd32|6b8e23|8fbc8f|adff2f)\b|"(?:[a-z]*green|lime)")))(?=[\s\S]*]*bpmnElement="Flow_PayOutClaimToClaimPaid")(?=[^>]*(?:#(?:c8e6c9|205022|e8f5e9|a5d6a7|81c784|66bb6a|4caf50|43a047|388e3c|2e7d32|1b5e20|b9f6ca|69f0ae|00e676|00c853|008000|00ff00|0f0|080|32cd32|228b22|006400|2e8b57|3cb371|90ee90|98fb98|00ff7f|7cfc00|7fff00|9acd32|6b8e23|8fbc8f|adff2f)\b|"(?:[a-z]*green|lime)")))' +pattern: '^(?=[\s\S]*]*bpmnElement="startEvent_claimReceived")(?=[^>]*(?:#(?:c8e6c9|205022|e8f5e9|a5d6a7|81c784|66bb6a|4caf50|43a047|388e3c|2e7d32|1b5e20|b9f6ca|69f0ae|00e676|00c853|008000|00ff00|0f0|080|32cd32|228b22|006400|2e8b57|3cb371|90ee90|98fb98|00ff7f|7cfc00|7fff00|9acd32|6b8e23|8fbc8f|adff2f)\b|"(?:[a-z]*green|lime)")))(?=[\s\S]*]*bpmnElement="userTask_assessClaim")(?=[^>]*(?:#(?:c8e6c9|205022|e8f5e9|a5d6a7|81c784|66bb6a|4caf50|43a047|388e3c|2e7d32|1b5e20|b9f6ca|69f0ae|00e676|00c853|008000|00ff00|0f0|080|32cd32|228b22|006400|2e8b57|3cb371|90ee90|98fb98|00ff7f|7cfc00|7fff00|9acd32|6b8e23|8fbc8f|adff2f)\b|"(?:[a-z]*green|lime)")))(?=[\s\S]*]*bpmnElement="serviceTask_payOutClaim")(?=[^>]*(?:#(?:c8e6c9|205022|e8f5e9|a5d6a7|81c784|66bb6a|4caf50|43a047|388e3c|2e7d32|1b5e20|b9f6ca|69f0ae|00e676|00c853|008000|00ff00|0f0|080|32cd32|228b22|006400|2e8b57|3cb371|90ee90|98fb98|00ff7f|7cfc00|7fff00|9acd32|6b8e23|8fbc8f|adff2f)\b|"(?:[a-z]*green|lime)")))(?=[\s\S]*]*bpmnElement="endEvent_claimPaid")(?=[^>]*(?:#(?:c8e6c9|205022|e8f5e9|a5d6a7|81c784|66bb6a|4caf50|43a047|388e3c|2e7d32|1b5e20|b9f6ca|69f0ae|00e676|00c853|008000|00ff00|0f0|080|32cd32|228b22|006400|2e8b57|3cb371|90ee90|98fb98|00ff7f|7cfc00|7fff00|9acd32|6b8e23|8fbc8f|adff2f)\b|"(?:[a-z]*green|lime)")))(?=[\s\S]*]*bpmnElement="flow_assessClaimToClaimCovered")(?=[^>]*(?:#(?:c8e6c9|205022|e8f5e9|a5d6a7|81c784|66bb6a|4caf50|43a047|388e3c|2e7d32|1b5e20|b9f6ca|69f0ae|00e676|00c853|008000|00ff00|0f0|080|32cd32|228b22|006400|2e8b57|3cb371|90ee90|98fb98|00ff7f|7cfc00|7fff00|9acd32|6b8e23|8fbc8f|adff2f)\b|"(?:[a-z]*green|lime)")))(?=[\s\S]*]*bpmnElement="flow_payOutClaimToClaimPaid")(?=[^>]*(?:#(?:c8e6c9|205022|e8f5e9|a5d6a7|81c784|66bb6a|4caf50|43a047|388e3c|2e7d32|1b5e20|b9f6ca|69f0ae|00e676|00c853|008000|00ff00|0f0|080|32cd32|228b22|006400|2e8b57|3cb371|90ee90|98fb98|00ff7f|7cfc00|7fff00|9acd32|6b8e23|8fbc8f|adff2f)\b|"(?:[a-z]*green|lime)")))' flags: i target: { source: file, path: claim-handling.bpmn } --- diff --git a/plugins/bpmn-cli/evals/format-happy-path/graders/main-line-kept.md b/plugins/bpmn-cli/evals/format-happy-path/graders/main-line-kept.md index 857fcc2a..f2ec488c 100644 --- a/plugins/bpmn-cli/evals/format-happy-path/graders/main-line-kept.md +++ b/plugins/bpmn-cli/evals/format-happy-path/graders/main-line-kept.md @@ -1,5 +1,5 @@ --- type: regex -pattern: '^(?=[\s\S]*bpmnElement="Activity_AssessClaim"[^>]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*bpmnElement="(?:Activity_SendRejectionLetter|Event_ClaimRejected|Flow_SendRejectionLetterToClaimRejected)")(?=[^>]*(?:#(?:c8e6c9|205022|e8f5e9|a5d6a7|81c784|66bb6a|4caf50|43a047|388e3c|2e7d32|1b5e20|b9f6ca|69f0ae|00e676|00c853|008000|00ff00|0f0|080|32cd32|228b22|006400|2e8b57|3cb371|90ee90|98fb98|00ff7f|7cfc00|7fff00|9acd32|6b8e23|8fbc8f|adff2f)\b|"(?:[a-z]*green|lime)"))' +pattern: ']*bpmnElement="(?:sendTask_sendRejectionLetter|endEvent_claimRejected|flow_sendRejectionLetterToClaimRejected)")(?=[^>]*(?:#(?:c8e6c9|205022|e8f5e9|a5d6a7|81c784|66bb6a|4caf50|43a047|388e3c|2e7d32|1b5e20|b9f6ca|69f0ae|00e676|00c853|008000|00ff00|0f0|080|32cd32|228b22|006400|2e8b57|3cb371|90ee90|98fb98|00ff7f|7cfc00|7fff00|9acd32|6b8e23|8fbc8f|adff2f)\b|"(?:[a-z]*green|lime)"))' flags: i match: 'not_contains' target: { source: file, path: claim-handling.bpmn } diff --git a/plugins/bpmn-cli/evals/format-happy-path/scaffold.sh b/plugins/bpmn-cli/evals/format-happy-path/scaffold.sh index b10ab2d4..a848f706 100755 --- a/plugins/bpmn-cli/evals/format-happy-path/scaffold.sh +++ b/plugins/bpmn-cli/evals/format-happy-path/scaffold.sh @@ -5,97 +5,97 @@ set -euo pipefail cat > claim-handling.bpmn <<'BPMN_FIXTURE' - - - Flow_ClaimReceivedToAssessClaim + + + flow_claimReceivedToAssessClaim - - Flow_ClaimReceivedToAssessClaim - Flow_AssessClaimToClaimCovered + + flow_claimReceivedToAssessClaim + flow_assessClaimToClaimCovered - - Flow_AssessClaimToClaimCovered - Flow_ClaimCoveredYes - Flow_ClaimCoveredNo + + flow_assessClaimToClaimCovered + flow_claimCoveredToPayOutClaim + flow_claimCoveredToSendRejectionLetter - - Flow_ClaimCoveredYes - Flow_PayOutClaimToClaimPaid + + flow_claimCoveredToPayOutClaim + flow_payOutClaimToClaimPaid - - Flow_PayOutClaimToClaimPaid + + flow_payOutClaimToClaimPaid - - Flow_ClaimCoveredNo - Flow_SendRejectionLetterToClaimRejected + + flow_claimCoveredToSendRejectionLetter + flow_sendRejectionLetterToClaimRejected - - Flow_SendRejectionLetterToClaimRejected + + flow_sendRejectionLetterToClaimRejected - - - + + + ${covered} - - - + + + - - + + - + - + - + - + - + - + - + - + - + - + - + @@ -103,7 +103,7 @@ cat > claim-handling.bpmn <<'BPMN_FIXTURE' - + diff --git a/plugins/bpmn-cli/evals/tidy-after-edit/graders/gateway-label-clear.md b/plugins/bpmn-cli/evals/tidy-after-edit/graders/gateway-label-clear.md index d41c1355..e5658b82 100644 --- a/plugins/bpmn-cli/evals/tidy-after-edit/graders/gateway-label-clear.md +++ b/plugins/bpmn-cli/evals/tidy-after-edit/graders/gateway-label-clear.md @@ -1,5 +1,5 @@ --- type: regex -pattern: 'bpmnElement="Gateway_InvoiceCorrect"[^>]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*\bid="([^"]+)"[^>]*\bname="Inform requester")(?=[\s\S]*]*\bsourceRef="Activity_ReturnInvoice"[^>]*\btargetRef="\1")(?=[\s\S]*]*\bsourceRef="\1"[^>]*\btargetRef="Event_InvoiceReturned")' +pattern: '^(?=[\s\S]*]*\bid="([^"]+)"[^>]*\bname="Inform requester")(?=[\s\S]*]*\bsourceRef="sendTask_returnInvoiceToSupplier"[^>]*\btargetRef="\1")(?=[\s\S]*]*\bsourceRef="\1"[^>]*\btargetRef="endEvent_invoiceReturned")' flags: i target: { source: file, path: invoice-approval.bpmn } --- diff --git a/plugins/bpmn-cli/evals/tidy-after-edit/graders/main-line-kept.md b/plugins/bpmn-cli/evals/tidy-after-edit/graders/main-line-kept.md index e42d461e..5a02b09e 100644 --- a/plugins/bpmn-cli/evals/tidy-after-edit/graders/main-line-kept.md +++ b/plugins/bpmn-cli/evals/tidy-after-edit/graders/main-line-kept.md @@ -1,5 +1,5 @@ --- type: regex -pattern: '^(?=[\s\S]*bpmnElement="Event_InvoiceReceived"[^>]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*\bid="([^"]+)"[^>]*\bname="Inform requester")(?:(?=[\s\S]*bpmnElement="Activity_RemindApprover"[^>]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*\bid="([^"]+)"[^>]*\bname="Inform requester")(?:(?=[\s\S]*bpmnElement="sendTask_remindApprover"[^>]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*>\s*]*>\s* invoice-approval.bpmn <<'BPMN_FIXTURE' - - - Flow_InvoiceReceivedToCheckInvoice + + + flow_invoiceReceivedToCheckInvoice - - Flow_InvoiceReceivedToCheckInvoice - Flow_CheckInvoiceToInvoiceCorrect + + flow_invoiceReceivedToCheckInvoice + flow_checkInvoiceToInvoiceCorrect - - Flow_CheckInvoiceToInvoiceCorrect - Flow_InvoiceCorrectYes - Flow_InvoiceCorrectNo + + flow_checkInvoiceToInvoiceCorrect + flow_invoiceCorrectToApproveInvoice + flow_invoiceCorrectToReturnInvoiceToSupplier - - Flow_InvoiceCorrectYes - Flow_ApproveInvoiceToPayInvoice + + flow_invoiceCorrectToApproveInvoice + flow_approveInvoiceToPayInvoice - - Flow_ApproveInvoiceToPayInvoice - Flow_PayInvoiceToInvoicePaid + + flow_approveInvoiceToPayInvoice + flow_payInvoiceToInvoicePaid - - Flow_PayInvoiceToInvoicePaid + + flow_payInvoiceToInvoicePaid - - Flow_InvoiceCorrectNo - Flow_ReturnInvoiceToInvoiceReturned + + flow_invoiceCorrectToReturnInvoiceToSupplier + flow_returnInvoiceToSupplierToInvoiceReturned - - Flow_ReturnInvoiceToInvoiceReturned + + flow_returnInvoiceToSupplierToInvoiceReturned - - Flow_ApprovalOverdueToRemindApprover - + + flow_approvalOverdueToRemindApprover + P5D - - Flow_ApprovalOverdueToRemindApprover - Flow_RemindApproverToApproverReminded + + flow_approvalOverdueToRemindApprover + flow_remindApproverToApproverReminded - - Flow_RemindApproverToApproverReminded + + flow_remindApproverToApproverReminded - - - + + + ${invoiceCorrect} - - - - - - + + + + + + - - + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + @@ -146,16 +146,16 @@ cat > invoice-approval.bpmn <<'BPMN_FIXTURE' - + - + - + diff --git a/plugins/bpmn-cli/skills/bpmn/SKILL.md b/plugins/bpmn-cli/skills/bpmn/SKILL.md index 0ba93214..c9bd78a8 100644 --- a/plugins/bpmn-cli/skills/bpmn/SKILL.md +++ b/plugins/bpmn-cli/skills/bpmn/SKILL.md @@ -53,17 +53,18 @@ installed version. Ask it instead of guessing: created them. Several hits for a name: decide by lane / neighbours (`show --context`) or ask. 3. **New ids speak.** In every batch give each element you create an explicit, - descriptive `"id"` in the file's style: `Activity_CheckInvoice`, - `Gateway_InvoiceOk`, `Event_InvoicePaid`, `Lane_Accounting`, - `Participant_Customer`; flows `Flow_CheckInvoiceToBookInvoice` (or let the - CLI derive them; derived ids speak too). PascalCase ASCII (ae oe ue ss), no + descriptive `"id"` in the project's id conventions: `userTask_checkInvoice`, + `gateway_invoiceOk`, `endEvent_invoicePaid`, `lane_accounting`, + `participant_customer`; flows `flow_checkInvoiceToBookInvoice` (or let the + CLI derive them; derived ids speak too). camelCase ASCII (ae oe ue ss), no numbers or hashes - also when the file's own ids are Modeler hashes like - `Activity_0k3x9qa`. Keep a file's existing prefix and case (`Task_check_stock`). - In a repository with a `.bpmnlintrc`, run `bpmn show --conventions` - before the first change (a file that does not exist yet too): where its - `element-id-naming` declares a prefix (`lint` rows), new ids follow it - instead (`serviceTask_checkInvoice`, `flow_checkInvoiceToBookInvoice`). - Write explicit ids in those patterns, or let the CLI derive them. A + `Activity_0k3x9qa`; existing ids stay as they are. + Run `bpmn show --conventions` before the first change (a file that + does not exist yet too): it lists the patterns new ids follow - the + `.bpmnlintrc`'s `element-id-naming` (`lint` rows), else the rule's defaults + (`default` rows), and bpmn-cli's prefixes for types the rule does not check + (`bpmn-cli` rows). Write explicit ids in those patterns, or let the CLI + derive them. A `warning W_LINT_UNAVAILABLE` there means the `.bpmnlintrc` did not load: fix it (or tell the user) before the first change. 4. **Back references inside a batch:** an id you set explicitly can be used by @@ -91,7 +92,7 @@ installed version. Ask it instead of guessing: boundary events, what catches it, message flows - `bpmn find [--kind userTask]` - ids for a name or a vendor value - `bpmn show --conventions` - the id patterns new ids follow (the - `.bpmnlintrc`'s `element-id-naming`, else the file's style); once per file + `.bpmnlintrc`'s `element-id-naming`, else its defaults); once per file - `bpmn show --layout` - the drawing: rows / columns of ids per lane, colours, layout problems (read it before formatting) 2. **Change** in one batch with explicit ids. Ops are the CLI flags in @@ -101,10 +102,10 @@ installed version. Ask it instead of guessing: ```bash printf '%s' '[ - { "op": "add", "kind": "boundaryEvent:timer", "name": "14 days", "id": "Event_PaymentOverdue", "on": "Activity_ReceivePayment", "timer": "P14D", "nonInterrupting": true }, - { "op": "add", "kind": "sendTask", "name": "Send payment reminder", "id": "Activity_SendPaymentReminder", "after": "Event_PaymentOverdue", "lane": "Lane_Accounting" }, - { "op": "add", "kind": "endEvent", "name": "Reminder sent", "id": "Event_ReminderSent", "after": "Activity_SendPaymentReminder", "lane": "Lane_Accounting" }, - { "op": "connect", "source": "Activity_SendPaymentReminder", "target": "Participant_Customer", "name": "Reminder" } + { "op": "add", "kind": "boundaryEvent:timer", "name": "14 days", "id": "event_paymentOverdue", "on": "receiveTask_receivePayment", "timer": "P14D", "nonInterrupting": true }, + { "op": "add", "kind": "sendTask", "name": "Send payment reminder", "id": "sendTask_sendPaymentReminder", "after": "event_paymentOverdue", "lane": "lane_accounting" }, + { "op": "add", "kind": "endEvent", "name": "Reminder sent", "id": "endEvent_reminderSent", "after": "sendTask_sendPaymentReminder", "lane": "lane_accounting" }, + { "op": "connect", "source": "sendTask_sendPaymentReminder", "target": "participant_customer", "name": "Reminder" } ]' | bpmn apply order-to-cash.bpmn - ``` @@ -144,9 +145,6 @@ installed version. Ask it instead of guessing: the file does not keep, which bpmnlint in CI reports too, also for the elements this change added (`bpmn validate ` lists them; with `--summary` they are a `validator bpmnlint:` line) - - `conventions: follow element-id-naming ...`: those new ids carry a - prefix the `.bpmnlintrc` declares, not the file's older style; use them as - printed. `warning W_ID_PREFIX_STALE ` after a retype or a trigger change: the id keeps a prefix the convention rejects; rename it with the `bpmn set` of its hint only when nothing else uses the id (ask the user) @@ -226,7 +224,7 @@ Exit codes: 0 ok, 1 usage, 2 model/validation error, 3 layout, 4 file/parse, - Flow options on add describe the flow **into** the new node: `flowName`, `condition`, `default`, `flowId`, `flowAs`. - `set` takes its changes in `values` (and `unset`): - `{ "op": "set", "id": "Flow_OrderOkYes", "values": { "name": "yes", "condition": "${orderOk}" } }`; + `{ "op": "set", "id": "flow_orderOkYes", "values": { "name": "yes", "condition": "${orderOk}" } }`; a `default` flow has no condition. - Pools: the first `participant` wraps the existing process; add further pools after it, `"blackBox": true` for a party without process (a customer). @@ -268,8 +266,8 @@ run in batch order, each after the layout of the ops before it. ```bash bpmn show order-to-cash.bpmn --layout -bpmn color order-to-cash.bpmn --path Event_OrderReceived Event_OrderCompleted --color green -bpmn place order-to-cash.bpmn --branch Flow_OrderOkToSendRejection --below Activity_CheckOrder --dry-run +bpmn color order-to-cash.bpmn --path startEvent_orderReceived endEvent_orderCompleted --color green +bpmn place order-to-cash.bpmn --branch flow_orderOkToSendRejection --below userTask_checkOrder --dry-run ``` Every command, selector and fix: [reference/formatting.md](reference/formatting.md). diff --git a/plugins/bpmn-cli/skills/bpmn/reference/camunda7.md b/plugins/bpmn-cli/skills/bpmn/reference/camunda7.md index 5e77a8dc..4bfb5b13 100644 --- a/plugins/bpmn-cli/skills/bpmn/reference/camunda7.md +++ b/plugins/bpmn-cli/skills/bpmn/reference/camunda7.md @@ -35,10 +35,10 @@ end / throw event), `loop.` (multi-instance: `loop.camunda:collection`, `bpmn kinds --section nestedKeys`. Quote values with `${...}` in single quotes. ```bash -bpmn set payment.bpmn Activity_InformCustomer camunda:candidateGroups=support camunda:formKey=camunda-forms:deployment:inform-customer.form -bpmn set payment.bpmn Event_CardDeclined definition.camunda:errorCodeVariable=declineCode definition.camunda:errorMessageVariable=declineMessage -bpmn set payment.bpmn Activity_ChargeCreditCard camunda:asyncBefore=true -bpmn add payment.bpmn userTask "Notify party" --id Activity_NotifyParty --after Activity_InformCustomer 'loop.camunda:collection=${parties}' loop.camunda:elementVariable=party 'camunda:assignee=${party}' +bpmn set payment.bpmn userTask_informCustomer camunda:candidateGroups=support camunda:formKey=camunda-forms:deployment:inform-customer.form +bpmn set payment.bpmn event_cardDeclined definition.camunda:errorCodeVariable=declineCode definition.camunda:errorMessageVariable=declineMessage +bpmn set payment.bpmn serviceTask_chargeCreditCard camunda:asyncBefore=true +bpmn add payment.bpmn userTask "Notify party" --id userTask_notifyParty --after userTask_informCustomer 'loop.camunda:collection=${parties}' loop.camunda:elementVariable=party 'camunda:assignee=${party}' ``` ## Extension elements: `ext add` @@ -50,19 +50,19 @@ Child types are filed into their container, created when missing a path reaches into nested containers. ```bash -bpmn ext add payment.bpmn Activity_ChargeCreditCard camunda:outputParameter name=transactionId --body '${transactionId}' -bpmn ext add payment.bpmn Activity_ChargeCreditCard camunda:failedJobRetryTimeCycle --body R3/PT5M -bpmn ext add payment.bpmn Activity_InformCustomer camunda:taskListener event=create 'expression=${task.setPriority(80)}' -bpmn ext add payment.bpmn Activity_NotifyParty camunda:formField id=notified type=boolean -bpmn ext add payment.bpmn Activity_NotifyParty 'camunda:formField[id=notified]/camunda:validation/camunda:constraint' name=required -bpmn ext list payment.bpmn Activity_ChargeCreditCard -bpmn ext remove payment.bpmn Activity_ChargeCreditCard 'camunda:outputParameter[name=transactionId]' +bpmn ext add payment.bpmn serviceTask_chargeCreditCard camunda:outputParameter name=transactionId --body '${transactionId}' +bpmn ext add payment.bpmn serviceTask_chargeCreditCard camunda:failedJobRetryTimeCycle --body R3/PT5M +bpmn ext add payment.bpmn userTask_informCustomer camunda:taskListener event=create 'expression=${task.setPriority(80)}' +bpmn ext add payment.bpmn userTask_notifyParty camunda:formField id=notified type=boolean +bpmn ext add payment.bpmn userTask_notifyParty 'camunda:formField[id=notified]/camunda:validation/camunda:constraint' name=required +bpmn ext list payment.bpmn serviceTask_chargeCreditCard +bpmn ext remove payment.bpmn serviceTask_chargeCreditCard 'camunda:outputParameter[name=transactionId]' ``` - Call activity mappings: `bpmn ext add camunda:in variables=all`, `camunda:in source=amount target=payoutAmount`, `camunda:out source=transactionId target=transactionId`. - External task error mapping: `camunda:errorEventDefinition` with `id`, - `errorRef` (the root error, e.g. `Error_CardDeclined` that an error boundary + `errorRef` (the root error, e.g. `error_cardDeclined` that an error boundary event created) and `expression` - see recipe 4. - A single container exists once (`E_DUPLICATE_EXTENSION` on a conflict); a repeatable element (listener, error mapping, form field) is changed by diff --git a/plugins/bpmn-cli/skills/bpmn/reference/camunda8.md b/plugins/bpmn-cli/skills/bpmn/reference/camunda8.md index 076c45ad..830a3595 100644 --- a/plugins/bpmn-cli/skills/bpmn/reference/camunda8.md +++ b/plugins/bpmn-cli/skills/bpmn/reference/camunda8.md @@ -32,16 +32,16 @@ creates them) and new event definitions an id. | condition | `add ... --condition '= amount > 1000'`, `set 'condition== amount > 1000'` | ```bash -bpmn ext add shipping.bpmn Activity_CorrectAddress zeebe:formDefinition formId=correct-address -bpmn ext add shipping.bpmn Activity_CorrectAddress zeebe:assignmentDefinition assignee==order.owner -bpmn add shipping.bpmn businessRuleTask "Choose carrier" --id Activity_ChooseCarrier --before Activity_CreateShippingLabel calledDecision=choose-carrier -bpmn ext add shipping.bpmn Activity_ChooseCarrier zeebe:calledDecision resultVariable=carrier -bpmn add shipping.bpmn callActivity "Notify customer" --id Activity_NotifyCustomer --after Activity_WaitForPickup -bpmn ext add shipping.bpmn Activity_NotifyCustomer zeebe:calledElement processId=notify-customer propagateAllChildVariables=false -bpmn ext add shipping.bpmn Activity_NotifyCustomer loop.zeebe:loopCharacteristics inputCollection==order.contacts inputElement=contact -bpmn ext add shipping.bpmn Activity_CreateShippingLabel zeebe:header key=carrier value=ups -bpmn ext list shipping.bpmn Activity_CreateShippingLabel -bpmn ext remove shipping.bpmn Activity_CreateShippingLabel 'zeebe:header[key=carrier]' +bpmn ext add shipping.bpmn userTask_correctAddress zeebe:formDefinition formId=correct-address +bpmn ext add shipping.bpmn userTask_correctAddress zeebe:assignmentDefinition assignee==order.owner +bpmn add shipping.bpmn businessRuleTask "Choose carrier" --id businessRuleTask_chooseCarrier --before serviceTask_createShippingLabel calledDecision=choose-carrier +bpmn ext add shipping.bpmn businessRuleTask_chooseCarrier zeebe:calledDecision resultVariable=carrier +bpmn add shipping.bpmn callActivity "Notify customer" --id callActivity_notifyCustomer --after receiveTask_waitForPickup +bpmn ext add shipping.bpmn callActivity_notifyCustomer zeebe:calledElement processId=notify-customer propagateAllChildVariables=false +bpmn ext add shipping.bpmn callActivity_notifyCustomer loop.zeebe:loopCharacteristics inputCollection==order.contacts inputElement=contact +bpmn ext add shipping.bpmn serviceTask_createShippingLabel zeebe:header key=carrier value=ups +bpmn ext list shipping.bpmn serviceTask_createShippingLabel +bpmn ext remove shipping.bpmn serviceTask_createShippingLabel 'zeebe:header[key=carrier]' bpmn validate shipping.bpmn --strict ``` diff --git a/plugins/bpmn-cli/skills/bpmn/reference/formatting.md b/plugins/bpmn-cli/skills/bpmn/reference/formatting.md index 84c18205..68eabefb 100644 --- a/plugins/bpmn-cli/skills/bpmn/reference/formatting.md +++ b/plugins/bpmn-cli/skills/bpmn/reference/formatting.md @@ -34,18 +34,18 @@ delete it or add it to `.gitignore`). # the whole diagram; every `metrics` problem numbered on the defect bpmn render order-to-cash.bpmn --problems -o .bpmn-render/order-to-cash.png # crops: the nodes of `show --around`, a sequence-flow path, a lane / pool / sub-process, these ids -bpmn render order-to-cash.bpmn --around Gateway_OrderOk --depth 2 --problems -o .bpmn-render/around.png -bpmn render order-to-cash.bpmn --path Event_OrderReceived Event_OrderCompleted -o .bpmn-render/path.png -bpmn render order-to-cash.bpmn --scope Lane_Accounting -o .bpmn-render/accounting.png -bpmn render order-to-cash.bpmn Activity_ReceivePayment Activity_SendPaymentReminder -o .bpmn-render/ids.png +bpmn render order-to-cash.bpmn --around gateway_orderOk --depth 2 --problems -o .bpmn-render/around.png +bpmn render order-to-cash.bpmn --path startEvent_orderReceived endEvent_orderCompleted -o .bpmn-render/path.png +bpmn render order-to-cash.bpmn --scope lane_accounting -o .bpmn-render/accounting.png +bpmn render order-to-cash.bpmn receiveTask_receivePayment sendTask_sendPaymentReminder -o .bpmn-render/ids.png # an orange halo around elements -bpmn render order-to-cash.bpmn --highlight Activity_SendInvoice,Activity_ReceivePayment -o .bpmn-render/halo.png +bpmn render order-to-cash.bpmn --highlight sendTask_sendInvoice,receiveTask_receivePayment -o .bpmn-render/halo.png ``` ```text -wrote .bpmn-render/order-to-cash.png 1382x727 px ~1,282 image tokens scale 1.00 plane Collaboration_OrderToCash +wrote .bpmn-render/order-to-cash.png 1382x727 px ~1,282 image tokens scale 1.00 plane collaboration_orderToCash problems (numbered in the image): - 1 labelOnLine [Flow_CustomerToReceivePayment, Flow_SendPaymentReminderToCustomer] + 1 labelOnLine [messageFlow_customerToReceivePayment, messageFlow_sendPaymentReminderToCustomer] ``` - The `wrote` line gives the size and the cost: image tokens = width x height @@ -56,7 +56,7 @@ problems (numbered in the image): only those with something in them, at most 40). - `--problems` lists the problems under the line, ` [] ` in `metrics` order (a crop: those of its elements), each number - drawn on the defect: `1 labelClash [Gateway_OrderOk, Activity_SendRejection] + drawn on the defect: `1 labelClash [gateway_orderOk, sendTask_sendRejection] label on shape`. Fix them by those ids with the commands below. - A crop draws flows to elements it leaves out faded, pools and lanes as context. A collapsed sub-process is its own plane: the result lists it @@ -78,7 +78,7 @@ to the ids: | `--branch ` | what only that branch reaches, up to the join or its ends | | `--kind ` | every element of a kind (`endEvent`, `userTask`, `sequenceFlow`, ...) | -In ops JSON: `"path": ["Event_A", "Event_B"]`, `"via": [...]`, `"branch": "Flow_X"`, +In ops JSON: `"path": ["startEvent_a", "endEvent_b"]`, `"via": [...]`, `"branch": "flow_x"`, `"kind": "endEvent"`. Shape commands leave boundary events out of a selection (they follow their host). @@ -86,27 +86,27 @@ In ops JSON: `"path": ["Event_A", "Event_B"]`, `"via": [...]`, `"branch": "Flow_ ```bash # colours (bpmn-js palette: blue, orange, green, red, purple; default removes) -bpmn color order-to-cash.bpmn --path Event_OrderReceived Event_OrderCompleted --color green -bpmn color order-to-cash.bpmn --branch Flow_OrderOkToSendRejection --color red -bpmn color order-to-cash.bpmn --branch Flow_OrderOkToSendRejection --color default +bpmn color order-to-cash.bpmn --path startEvent_orderReceived endEvent_orderCompleted --color green +bpmn color order-to-cash.bpmn --branch flow_orderOkToSendRejection --color red +bpmn color order-to-cash.bpmn --branch flow_orderOkToSendRejection --color default # the space tool: one column more right of a node, then close it again -bpmn space order-to-cash.bpmn --after Activity_CheckOrder -bpmn space order-to-cash.bpmn --after Activity_CheckOrder --by -column +bpmn space order-to-cash.bpmn --after userTask_checkOrder +bpmn space order-to-cash.bpmn --after userTask_checkOrder --by -column # external labels of events, gateways, data and flows -bpmn label order-to-cash.bpmn Gateway_OrderOk --side below +bpmn label order-to-cash.bpmn gateway_orderOk --side below # route one flow again, forcing the sides it leaves and enters by -bpmn route order-to-cash.bpmn Flow_PaymentOverdueToSendPaymentReminder --exit bottom --entry left +bpmn route order-to-cash.bpmn flow_paymentOverdueToSendPaymentReminder --exit bottom --entry left # same column (horizontal centre) as the reference given with --to -bpmn align order-to-cash.bpmn Event_ReminderSent Event_OrderCompleted --axis column --to Event_OrderCompleted +bpmn align order-to-cash.bpmn endEvent_reminderSent endEvent_orderCompleted --axis column --to endEvent_orderCompleted # order of branches (top to bottom), lanes and pools -bpmn order order-to-cash.bpmn Gateway_OrderOk Flow_OrderOkToSendRejection Flow_OrderOkToPickAndPackGoods -bpmn order order-to-cash.bpmn Participant_OrderToCash Lane_Sales Lane_Warehouse Lane_Accounting -bpmn order order-to-cash.bpmn Collaboration_OrderToCash Participant_Customer Participant_OrderToCash +bpmn order order-to-cash.bpmn gateway_orderOk flow_orderOkToSendRejection flow_orderOkToPickAndPackGoods +bpmn order order-to-cash.bpmn participant_orderToCash lane_sales lane_warehouse lane_accounting +bpmn order order-to-cash.bpmn collaboration_orderToCash participant_customer participant_orderToCash # overlaps away, then close empty rows / columns and shrink frames bpmn tidy order-to-cash.bpmn @@ -144,9 +144,9 @@ run shows what moves): ```bash bpmn show order-to-cash.bpmn --layout -bpmn space order-to-cash.bpmn --below Lane_Sales -bpmn place order-to-cash.bpmn --branch Flow_OrderOkToSendRejection --below Activity_CheckOrder --dry-run -bpmn place order-to-cash.bpmn --branch Flow_OrderOkToSendRejection --below Activity_CheckOrder +bpmn space order-to-cash.bpmn --below lane_sales +bpmn place order-to-cash.bpmn --branch flow_orderOkToSendRejection --below userTask_checkOrder --dry-run +bpmn place order-to-cash.bpmn --branch flow_orderOkToSendRejection --below userTask_checkOrder bpmn show order-to-cash.bpmn --layout ``` @@ -154,9 +154,9 @@ When a node should land in another lane, change its lane first: `place` refuses to move a node out of its lane, pool or sub-process. ```text -$ bpmn place order-to-cash.bpmn Activity_SendInvoice --row-of Activity_ReceivePayment -error E_LEAVES_CONTAINER: Placing Activity_SendInvoice in the row of Activity_ReceivePayment would move it out of its lane Lane_Sales (into lane Lane_Accounting) - hint: That position is in lane Lane_Accounting. Assign the lane first (`bpmn move Activity_SendInvoice --lane Lane_Accounting`, in a batch a move op with "lane" before this op), or make room in Lane_Sales first (`bpmn space --below Lane_Sales`). +$ bpmn place order-to-cash.bpmn sendTask_sendInvoice --row-of receiveTask_receivePayment +error E_LEAVES_CONTAINER: Placing sendTask_sendInvoice in the row of receiveTask_receivePayment would move it out of its lane lane_sales (into lane lane_accounting) + hint: That position is in lane lane_accounting. Assign the lane first (`bpmn move sendTask_sendInvoice --lane lane_accounting`, in a batch a move op with "lane" before this op), or make room in lane_sales first (`bpmn space --below lane_sales`). ``` Fixes: `move --lane ` when the node really belongs to the other @@ -182,10 +182,10 @@ model op keeps what they drew: ```bash printf '%s' '[ - { "op": "move", "ids": ["Activity_SendInvoice"], "lane": "Lane_Accounting" }, - { "op": "color", "path": ["Event_OrderReceived", "Event_OrderCompleted"], "color": "green" }, - { "op": "color", "ids": ["Activity_SendPaymentReminder", "Event_ReminderSent"], "color": "orange" }, - { "op": "label", "id": "Gateway_OrderOk", "side": "above" }, + { "op": "move", "ids": ["sendTask_sendInvoice"], "lane": "lane_accounting" }, + { "op": "color", "path": ["startEvent_orderReceived", "endEvent_orderCompleted"], "color": "green" }, + { "op": "color", "ids": ["sendTask_sendPaymentReminder", "endEvent_reminderSent"], "color": "orange" }, + { "op": "label", "id": "gateway_orderOk", "side": "above" }, { "op": "compact" } ]' | bpmn apply order-to-cash.bpmn - --summary --render .bpmn-render/order-to-cash.png bpmn metrics order-to-cash.bpmn diff --git a/plugins/bpmn-cli/skills/bpmn/reference/ops.md b/plugins/bpmn-cli/skills/bpmn/reference/ops.md index fbe35b27..c3227b5c 100644 --- a/plugins/bpmn-cli/skills/bpmn/reference/ops.md +++ b/plugins/bpmn-cli/skills/bpmn/reference/ops.md @@ -40,13 +40,13 @@ Trigger keys: `timer`, `timerKind`, `message`, `error`, `errorCode`, `signal`, ## Ids and aliases -Give every created element an explicit speaking `id` (`Activity_`, -`Gateway_`, `Event_`, `Lane_`, `Participant_`, -`DataObjectReference_`, `DataStoreReference_`, -`TextAnnotation_`); later ops of the batch use it directly. Flow ids the -CLI derives speak too (`Flow_CheckInvoiceToBookInvoice`). In a repository whose -`.bpmnlintrc` declares id conventions (`bpmn show --conventions`), use -its patterns instead (`serviceTask_checkInvoice`, `flow_checkInvoiceToBookInvoice`). For an element you do +Give every created element an explicit speaking `id` in the patterns +`bpmn show --conventions` lists; without a `.bpmnlintrc` these are +`element-id-naming`'s defaults (`serviceTask_`, `userTask_`, +`gateway_`, `startEvent_`, `endEvent_`, `event_`, +`lane_`, `participant_`, `dataObject_`, `dataStore_`, +`textAnnotation_`); later ops of the batch use it directly. Flow ids the +CLI derives speak too (`flow_checkInvoiceToBookInvoice`). For an element you do not name, set an alias - `"as"` on add / connect / split and split nodes, `"flowAs"` for the flow into a new node, `"outAs"` for the flow an add creates out of it (on a splice: to the old successor, so the same batch can set its @@ -59,18 +59,18 @@ works in one batch. ## A first batch: split, aliases, end ```bash -bpmn new invoice.bpmn --name "Invoice approval" --id Process_InvoiceApproval +bpmn new invoice.bpmn --name "Invoice approval" --id invoiceApproval printf '%s' '[ - { "op": "add", "kind": "startEvent", "name": "Invoice received", "id": "Event_InvoiceReceived", "in": "Process_InvoiceApproval" }, - { "op": "add", "kind": "userTask", "name": "Check invoice", "id": "Activity_CheckInvoice", "after": "Event_InvoiceReceived" }, - { "op": "split", "after": "Activity_CheckInvoice", "kind": "exclusiveGateway", "name": "Invoice ok?", "id": "Gateway_InvoiceOk", "joinId": "Gateway_InvoiceOkJoin", + { "op": "add", "kind": "startEvent", "name": "Invoice received", "id": "startEvent_invoiceReceived", "in": "invoiceApproval" }, + { "op": "add", "kind": "userTask", "name": "Check invoice", "id": "userTask_checkInvoice", "after": "startEvent_invoiceReceived" }, + { "op": "split", "after": "userTask_checkInvoice", "kind": "exclusiveGateway", "name": "Invoice ok?", "id": "gateway_invoiceOk", "joinId": "gateway_invoiceOkJoin", "branches": [ - { "flowName": "yes", "condition": "${invoiceOk}", "nodes": [ { "kind": "serviceTask", "name": "Book invoice", "id": "Activity_BookInvoice" } ] }, - { "flowName": "no", "nodes": [ { "kind": "userTask", "name": "Clarify invoice", "id": "Activity_ClarifyInvoice", "flowAs": "$toClarify" }, - { "kind": "sendTask", "name": "Inform supplier", "id": "Activity_InformSupplier" } ] } + { "flowName": "yes", "condition": "${invoiceOk}", "nodes": [ { "kind": "serviceTask", "name": "Book invoice", "id": "serviceTask_bookInvoice" } ] }, + { "flowName": "no", "nodes": [ { "kind": "userTask", "name": "Clarify invoice", "id": "userTask_clarifyInvoice", "flowAs": "$toClarify" }, + { "kind": "sendTask", "name": "Inform supplier", "id": "sendTask_informSupplier" } ] } ] }, - { "op": "set", "id": "Gateway_InvoiceOk", "values": { "default": "$toClarify" } }, - { "op": "add", "kind": "endEvent", "name": "Invoice handled", "id": "Event_InvoiceHandled", "after": "Gateway_InvoiceOkJoin" } + { "op": "set", "id": "gateway_invoiceOk", "values": { "default": "$toClarify" } }, + { "op": "add", "kind": "endEvent", "name": "Invoice handled", "id": "endEvent_invoiceHandled", "after": "gateway_invoiceOkJoin" } ]' | bpmn apply invoice.bpmn - bpmn show invoice.bpmn ``` @@ -98,16 +98,16 @@ no placement: `members` names what it is drawn around. ```bash printf '%s' '[ - { "op": "add", "kind": "dataObject", "name": "Invoice", "id": "DataObjectReference_Invoice", "in": "Process_InvoiceApproval" }, - { "op": "connect", "source": "DataObjectReference_Invoice", "target": "Activity_CheckInvoice" }, - { "op": "add", "kind": "dataStore", "name": "Ledger", "id": "DataStoreReference_Ledger", "in": "Process_InvoiceApproval" }, - { "op": "connect", "source": "Activity_BookInvoice", "target": "DataStoreReference_Ledger" }, - { "op": "add", "kind": "boundaryEvent:timer", "name": "5 days", "id": "Event_ClarificationOverdue", "on": "Activity_ClarifyInvoice", "timer": "P5D" }, - { "op": "add", "kind": "endEvent", "name": "Invoice returned", "id": "Event_InvoiceReturned", "after": "Event_ClarificationOverdue" }, - { "op": "add", "kind": "subProcess", "name": "Archive invoice", "id": "Activity_ArchiveInvoice", "before": "Event_InvoiceHandled" }, - { "op": "add", "kind": "startEvent", "name": "Archiving started", "id": "Event_ArchivingStarted", "in": "Activity_ArchiveInvoice" }, - { "op": "add", "kind": "serviceTask", "name": "Store PDF", "id": "Activity_StorePdf", "after": "Event_ArchivingStarted" }, - { "op": "add", "kind": "endEvent", "name": "Archived", "id": "Event_Archived", "after": "Activity_StorePdf" } + { "op": "add", "kind": "dataObject", "name": "Invoice", "id": "dataObject_invoice", "in": "invoiceApproval" }, + { "op": "connect", "source": "dataObject_invoice", "target": "userTask_checkInvoice" }, + { "op": "add", "kind": "dataStore", "name": "Ledger", "id": "dataStore_ledger", "in": "invoiceApproval" }, + { "op": "connect", "source": "serviceTask_bookInvoice", "target": "dataStore_ledger" }, + { "op": "add", "kind": "boundaryEvent:timer", "name": "5 days", "id": "event_clarificationOverdue", "on": "userTask_clarifyInvoice", "timer": "P5D" }, + { "op": "add", "kind": "endEvent", "name": "Invoice returned", "id": "endEvent_invoiceReturned", "after": "event_clarificationOverdue" }, + { "op": "add", "kind": "subProcess", "name": "Archive invoice", "id": "subProcess_archiveInvoice", "before": "endEvent_invoiceHandled" }, + { "op": "add", "kind": "startEvent", "name": "Archiving started", "id": "startEvent_archivingStarted", "in": "subProcess_archiveInvoice" }, + { "op": "add", "kind": "serviceTask", "name": "Store PDF", "id": "serviceTask_storePdf", "after": "startEvent_archivingStarted" }, + { "op": "add", "kind": "endEvent", "name": "Archived", "id": "endEvent_archived", "after": "serviceTask_storePdf" } ]' | bpmn apply invoice.bpmn - --summary ``` @@ -121,13 +121,13 @@ printf '%s' '[ ```bash printf '%s' '[ - { "op": "set", "id": "Activity_CheckInvoice", "values": { "name": "Check invoice and order", "doc": "Compare the invoice with the purchase order." } }, - { "op": "set", "id": "Activity_StorePdf", "values": { "loop": "parallel", "cardinality": "${count}" } }, - { "op": "retype", "id": "Event_InvoiceReceived", "kind": "startEvent:message", "message": "Invoice" }, - { "op": "retype", "id": "Activity_BookInvoice", "kind": "scriptTask" }, - { "op": "remove", "ids": ["Activity_InformSupplier"] }, - { "op": "add", "kind": "endEvent", "name": "Invoice handled", "id": "Event_InvoiceHandled", "ifAbsent": true, "in": "Process_InvoiceApproval" }, - { "op": "remove", "ids": ["Activity_PayInvoice"], "ifExists": true } + { "op": "set", "id": "userTask_checkInvoice", "values": { "name": "Check invoice and order", "doc": "Compare the invoice with the purchase order." } }, + { "op": "set", "id": "serviceTask_storePdf", "values": { "loop": "parallel", "cardinality": "${count}" } }, + { "op": "retype", "id": "startEvent_invoiceReceived", "kind": "startEvent:message", "message": "Invoice" }, + { "op": "retype", "id": "serviceTask_bookInvoice", "kind": "scriptTask" }, + { "op": "remove", "ids": ["sendTask_informSupplier"] }, + { "op": "add", "kind": "endEvent", "name": "Invoice handled", "id": "endEvent_invoiceHandled", "ifAbsent": true, "in": "invoiceApproval" }, + { "op": "remove", "ids": ["serviceTask_payInvoice"], "ifExists": true } ]' | bpmn apply invoice.bpmn - --summary bpmn validate invoice.bpmn ``` @@ -162,6 +162,6 @@ Tasks: `task`, `userTask`, `serviceTask`, `sendTask`, `receiveTask`, fits which event: `bpmn kinds --section kinds,triggers`. Timers are ISO 8601: `PT2H` / `P3D` (duration), `R/PT1H` (cycle), a date-time (date). Messages, errors, signals and escalations are root elements found by name or created -(ids in the file's style, e.g. `Message_`); never guess those ids: name +(ids like `message_`); never guess those ids: name them in a batch with `"refAs": "$name"` on add / connect / set / retype, and the result lists them. diff --git a/plugins/bpmn-cli/skills/bpmn/reference/recipes.md b/plugins/bpmn-cli/skills/bpmn/reference/recipes.md index 24c5a9ee..dd4c5b59 100644 --- a/plugins/bpmn-cli/skills/bpmn/reference/recipes.md +++ b/plugins/bpmn-cli/skills/bpmn/reference/recipes.md @@ -14,29 +14,29 @@ customer pool: one `new`, one `apply`, one `validate`. Every element gets an explicit speaking id, so later ops (and later sessions) can refer to it. ```bash -bpmn new order-to-cash.bpmn --name "Order to cash" --id Process_OrderToCash +bpmn new order-to-cash.bpmn --name "Order to cash" --id orderToCash printf '%s' '[ - { "op": "add", "kind": "participant", "name": "Order to cash", "id": "Participant_OrderToCash" }, - { "op": "add", "kind": "participant", "name": "Customer", "id": "Participant_Customer", "blackBox": true }, - { "op": "add", "kind": "lane", "name": "Sales", "id": "Lane_Sales", "in": "Participant_OrderToCash" }, - { "op": "add", "kind": "lane", "name": "Warehouse", "id": "Lane_Warehouse", "in": "Participant_OrderToCash" }, - { "op": "add", "kind": "lane", "name": "Accounting", "id": "Lane_Accounting", "in": "Participant_OrderToCash" }, - - { "op": "add", "kind": "startEvent:message", "name": "Order received", "id": "Event_OrderReceived", "message": "Order", "in": "Participant_OrderToCash", "lane": "Lane_Sales" }, - { "op": "add", "kind": "userTask", "name": "Check order", "id": "Activity_CheckOrder", "after": "Event_OrderReceived", "lane": "Lane_Sales" }, - { "op": "add", "kind": "exclusiveGateway", "name": "Order ok?", "id": "Gateway_OrderOk", "after": "Activity_CheckOrder", "lane": "Lane_Sales" }, - { "op": "add", "kind": "userTask", "name": "Pick and pack goods", "id": "Activity_PickAndPackGoods", "after": "Gateway_OrderOk", "flowName": "yes", "condition": "${orderOk}", "lane": "Lane_Warehouse" }, - { "op": "add", "kind": "userTask", "name": "Ship goods", "id": "Activity_ShipGoods", "after": "Activity_PickAndPackGoods", "lane": "Lane_Warehouse" }, - { "op": "add", "kind": "sendTask", "name": "Send invoice", "id": "Activity_SendInvoice", "after": "Activity_ShipGoods", "lane": "Lane_Accounting" }, - { "op": "add", "kind": "receiveTask", "name": "Receive payment", "id": "Activity_ReceivePayment", "message": "Payment", "after": "Activity_SendInvoice", "lane": "Lane_Accounting" }, - { "op": "add", "kind": "endEvent", "name": "Order completed", "id": "Event_OrderCompleted", "after": "Activity_ReceivePayment", "lane": "Lane_Accounting" }, - { "op": "add", "kind": "sendTask", "name": "Send rejection", "id": "Activity_SendRejection", "after": "Gateway_OrderOk", "flowName": "no", "default": true, "lane": "Lane_Sales" }, - { "op": "add", "kind": "endEvent", "name": "Order rejected", "id": "Event_OrderRejected", "after": "Activity_SendRejection", "lane": "Lane_Sales" }, - - { "op": "connect", "source": "Participant_Customer", "target": "Event_OrderReceived", "name": "Order", "id": "Flow_CustomerToOrderReceived" }, - { "op": "connect", "source": "Activity_SendRejection", "target": "Participant_Customer", "name": "Rejection", "id": "Flow_SendRejectionToCustomer" }, - { "op": "connect", "source": "Activity_SendInvoice", "target": "Participant_Customer", "name": "Invoice", "id": "Flow_SendInvoiceToCustomer" }, - { "op": "connect", "source": "Participant_Customer", "target": "Activity_ReceivePayment", "name": "Payment", "id": "Flow_CustomerToReceivePayment" } + { "op": "add", "kind": "participant", "name": "Order to cash", "id": "participant_orderToCash" }, + { "op": "add", "kind": "participant", "name": "Customer", "id": "participant_customer", "blackBox": true }, + { "op": "add", "kind": "lane", "name": "Sales", "id": "lane_sales", "in": "participant_orderToCash" }, + { "op": "add", "kind": "lane", "name": "Warehouse", "id": "lane_warehouse", "in": "participant_orderToCash" }, + { "op": "add", "kind": "lane", "name": "Accounting", "id": "lane_accounting", "in": "participant_orderToCash" }, + + { "op": "add", "kind": "startEvent:message", "name": "Order received", "id": "startEvent_orderReceived", "message": "Order", "in": "participant_orderToCash", "lane": "lane_sales" }, + { "op": "add", "kind": "userTask", "name": "Check order", "id": "userTask_checkOrder", "after": "startEvent_orderReceived", "lane": "lane_sales" }, + { "op": "add", "kind": "exclusiveGateway", "name": "Order ok?", "id": "gateway_orderOk", "after": "userTask_checkOrder", "lane": "lane_sales" }, + { "op": "add", "kind": "userTask", "name": "Pick and pack goods", "id": "userTask_pickAndPackGoods", "after": "gateway_orderOk", "flowName": "yes", "condition": "${orderOk}", "lane": "lane_warehouse" }, + { "op": "add", "kind": "userTask", "name": "Ship goods", "id": "userTask_shipGoods", "after": "userTask_pickAndPackGoods", "lane": "lane_warehouse" }, + { "op": "add", "kind": "sendTask", "name": "Send invoice", "id": "sendTask_sendInvoice", "after": "userTask_shipGoods", "lane": "lane_accounting" }, + { "op": "add", "kind": "receiveTask", "name": "Receive payment", "id": "receiveTask_receivePayment", "message": "Payment", "after": "sendTask_sendInvoice", "lane": "lane_accounting" }, + { "op": "add", "kind": "endEvent", "name": "Order completed", "id": "endEvent_orderCompleted", "after": "receiveTask_receivePayment", "lane": "lane_accounting" }, + { "op": "add", "kind": "sendTask", "name": "Send rejection", "id": "sendTask_sendRejection", "after": "gateway_orderOk", "flowName": "no", "default": true, "lane": "lane_sales" }, + { "op": "add", "kind": "endEvent", "name": "Order rejected", "id": "endEvent_orderRejected", "after": "sendTask_sendRejection", "lane": "lane_sales" }, + + { "op": "connect", "source": "participant_customer", "target": "startEvent_orderReceived", "name": "Order", "id": "messageFlow_customerToOrderReceived" }, + { "op": "connect", "source": "sendTask_sendRejection", "target": "participant_customer", "name": "Rejection", "id": "messageFlow_sendRejectionToCustomer" }, + { "op": "connect", "source": "sendTask_sendInvoice", "target": "participant_customer", "name": "Invoice", "id": "messageFlow_sendInvoiceToCustomer" }, + { "op": "connect", "source": "participant_customer", "target": "receiveTask_receivePayment", "name": "Payment", "id": "messageFlow_customerToReceivePayment" } ]' | bpmn apply order-to-cash.bpmn - --summary bpmn validate order-to-cash.bpmn --strict bpmn show order-to-cash.bpmn @@ -47,7 +47,7 @@ Notes: - The first `participant` wraps the existing process; the customer is a black box (no process). Lanes go `in` the pool; every node names its `lane`. - Sequence flows get speaking ids from their ends - (`Flow_CheckOrderToOrderOk`); message flows here got explicit ones. + (`flow_checkOrderToOrderOk`); message flows here got explicit ones. - The first outgoing flow of a gateway that leads on (or the default flow) continues straight; `bpmn order ` changes the order. - `validate --strict` exits 0 only without warnings; the file has no engine @@ -61,33 +61,33 @@ mode refuses to redraw instead of redrawing), apply it, and check that no layout problem was added. ```bash -bpmn show order-to-cash.bpmn --around Gateway_OrderOk --depth 1 -bpmn show order-to-cash.bpmn Activity_ReceivePayment --context +bpmn show order-to-cash.bpmn --around gateway_orderOk --depth 1 +bpmn show order-to-cash.bpmn receiveTask_receivePayment --context bpmn show order-to-cash.bpmn --layout printf '%s' '[ - { "op": "add", "kind": "userTask", "name": "Check credit limit", "id": "Activity_CheckCreditLimit", "flow": "Flow_CheckOrderToOrderOk", "lane": "Lane_Sales" }, - { "op": "add", "kind": "boundaryEvent:timer", "name": "14 days", "id": "Event_PaymentOverdue", "on": "Activity_ReceivePayment", "timer": "P14D", "nonInterrupting": true }, - { "op": "add", "kind": "sendTask", "name": "Send payment reminder", "id": "Activity_SendPaymentReminder", "after": "Event_PaymentOverdue", "lane": "Lane_Accounting" }, - { "op": "add", "kind": "endEvent", "name": "Reminder sent", "id": "Event_ReminderSent", "after": "Activity_SendPaymentReminder", "lane": "Lane_Accounting" }, - { "op": "connect", "source": "Activity_SendPaymentReminder", "target": "Participant_Customer", "name": "Reminder", "id": "Flow_SendPaymentReminderToCustomer" } + { "op": "add", "kind": "userTask", "name": "Check credit limit", "id": "userTask_checkCreditLimit", "flow": "flow_checkOrderToOrderOk", "lane": "lane_sales" }, + { "op": "add", "kind": "boundaryEvent:timer", "name": "14 days", "id": "event_paymentOverdue", "on": "receiveTask_receivePayment", "timer": "P14D", "nonInterrupting": true }, + { "op": "add", "kind": "sendTask", "name": "Send payment reminder", "id": "sendTask_sendPaymentReminder", "after": "event_paymentOverdue", "lane": "lane_accounting" }, + { "op": "add", "kind": "endEvent", "name": "Reminder sent", "id": "endEvent_reminderSent", "after": "sendTask_sendPaymentReminder", "lane": "lane_accounting" }, + { "op": "connect", "source": "sendTask_sendPaymentReminder", "target": "participant_customer", "name": "Reminder", "id": "messageFlow_sendPaymentReminderToCustomer" } ]' | bpmn apply order-to-cash.bpmn - --dry-run --layout incremental ``` The dry run prints exactly what the write will do: the created ids, the flow that was split (`flow` splices into it; it keeps its name and condition, and a flow whose id named its old ends is renamed, here -`Flow_CheckOrderToOrderOk -> Flow_CheckOrderToCheckCreditLimit`, unless flow +`flow_checkOrderToOrderOk -> flow_checkOrderToCheckCreditLimit`, unless flow ids are kept with `--keep-flow-ids`), what the layout `placed` / `moved` / `rerouted`, and `layout quality: a -> b` with any `added:` problem. When it looks right, run the same batch without `--dry-run`: ```bash printf '%s' '[ - { "op": "add", "kind": "userTask", "name": "Check credit limit", "id": "Activity_CheckCreditLimit", "flow": "Flow_CheckOrderToOrderOk", "lane": "Lane_Sales" }, - { "op": "add", "kind": "boundaryEvent:timer", "name": "14 days", "id": "Event_PaymentOverdue", "on": "Activity_ReceivePayment", "timer": "P14D", "nonInterrupting": true }, - { "op": "add", "kind": "sendTask", "name": "Send payment reminder", "id": "Activity_SendPaymentReminder", "after": "Event_PaymentOverdue", "lane": "Lane_Accounting" }, - { "op": "add", "kind": "endEvent", "name": "Reminder sent", "id": "Event_ReminderSent", "after": "Activity_SendPaymentReminder", "lane": "Lane_Accounting" }, - { "op": "connect", "source": "Activity_SendPaymentReminder", "target": "Participant_Customer", "name": "Reminder", "id": "Flow_SendPaymentReminderToCustomer" } + { "op": "add", "kind": "userTask", "name": "Check credit limit", "id": "userTask_checkCreditLimit", "flow": "flow_checkOrderToOrderOk", "lane": "lane_sales" }, + { "op": "add", "kind": "boundaryEvent:timer", "name": "14 days", "id": "event_paymentOverdue", "on": "receiveTask_receivePayment", "timer": "P14D", "nonInterrupting": true }, + { "op": "add", "kind": "sendTask", "name": "Send payment reminder", "id": "sendTask_sendPaymentReminder", "after": "event_paymentOverdue", "lane": "lane_accounting" }, + { "op": "add", "kind": "endEvent", "name": "Reminder sent", "id": "endEvent_reminderSent", "after": "sendTask_sendPaymentReminder", "lane": "lane_accounting" }, + { "op": "connect", "source": "sendTask_sendPaymentReminder", "target": "participant_customer", "name": "Reminder", "id": "messageFlow_sendPaymentReminderToCustomer" } ]' | bpmn apply order-to-cash.bpmn - --layout incremental bpmn metrics order-to-cash.bpmn bpmn validate order-to-cash.bpmn @@ -114,39 +114,39 @@ connected to its successor). ```bash printf '%s' '[ - { "op": "set", "id": "Activity_CheckOrder", "values": { "name": "Check order and stock", "doc": "Sales checks prices, terms and stock." } }, - { "op": "retype", "id": "Activity_ShipGoods", "kind": "serviceTask" }, - { "op": "set", "id": "Event_PaymentOverdue", "values": { "timer": "P10D", "name": "10 days" } }, - { "op": "remove", "ids": ["Activity_CheckCreditLimit"] }, - { "op": "move", "ids": ["Activity_SendInvoice"], "lane": "Lane_Sales" } + { "op": "set", "id": "userTask_checkOrder", "values": { "name": "Check order and stock", "doc": "Sales checks prices, terms and stock." } }, + { "op": "retype", "id": "userTask_shipGoods", "kind": "serviceTask" }, + { "op": "set", "id": "event_paymentOverdue", "values": { "timer": "P10D", "name": "10 days" } }, + { "op": "remove", "ids": ["userTask_checkCreditLimit"] }, + { "op": "move", "ids": ["sendTask_sendInvoice"], "lane": "lane_sales" } ]' | bpmn apply order-to-cash.bpmn - --summary bpmn validate order-to-cash.bpmn -bpmn show order-to-cash.bpmn --around Activity_SendInvoice --depth 1 +bpmn show order-to-cash.bpmn --around sendTask_sendInvoice --depth 1 ``` - `--summary` lists `created : ids`, `changed:`, `renamed:`, `removed:`, the added warnings and one layout line. Here the bridge renamed - `Flow_CheckOrderToCheckCreditLimit` back to `Flow_CheckOrderToOrderOk`. + `flow_checkOrderToCheckCreditLimit` back to `flow_checkOrderToOrderOk`. - `move` with only `lane` changes the lane; with `after` / `before` / `flow` it moves the node to another place in the flow (bridging the old one). ## 4. Camunda 7: external task with input and error mapping ```bash -bpmn new payment.bpmn --name "Payment" --id Process_Payment --target camunda7 +bpmn new payment.bpmn --name "Payment" --id payment --target camunda7 printf '%s' '[ - { "op": "add", "kind": "startEvent", "name": "Payment requested", "id": "Event_PaymentRequested", "in": "Process_Payment" }, - { "op": "add", "kind": "serviceTask", "name": "Charge credit card", "id": "Activity_ChargeCreditCard", "after": "Event_PaymentRequested" }, - { "op": "add", "kind": "endEvent", "name": "Payment received", "id": "Event_PaymentReceived", "after": "Activity_ChargeCreditCard" }, - { "op": "set", "id": "Activity_ChargeCreditCard", "values": { "camunda:type": "external", "camunda:topic": "charge-credit-card" } }, - { "op": "ext", "id": "Activity_ChargeCreditCard", "action": "add", "type": "camunda:inputParameter", "attrs": { "name": "amount" }, "body": "${order.total}" }, - { "op": "add", "kind": "boundaryEvent:error", "name": "Card declined", "id": "Event_CardDeclined", "on": "Activity_ChargeCreditCard", "error": "Card declined", "errorCode": "CARD_DECLINED" }, - { "op": "add", "kind": "userTask", "name": "Inform customer", "id": "Activity_InformCustomer", "after": "Event_CardDeclined" }, - { "op": "add", "kind": "endEvent", "name": "Payment failed", "id": "Event_PaymentFailed", "after": "Activity_InformCustomer" }, - { "op": "ext", "id": "Activity_ChargeCreditCard", "action": "add", "type": "camunda:errorEventDefinition", "attrs": { "id": "ErrorMapping_CardDeclined", "errorRef": "Error_CardDeclined", "expression": "${declined}" } } + { "op": "add", "kind": "startEvent", "name": "Payment requested", "id": "startEvent_paymentRequested", "in": "payment" }, + { "op": "add", "kind": "serviceTask", "name": "Charge credit card", "id": "serviceTask_chargeCreditCard", "after": "startEvent_paymentRequested" }, + { "op": "add", "kind": "endEvent", "name": "Payment received", "id": "endEvent_paymentReceived", "after": "serviceTask_chargeCreditCard" }, + { "op": "set", "id": "serviceTask_chargeCreditCard", "values": { "camunda:type": "external", "camunda:topic": "charge-credit-card" } }, + { "op": "ext", "id": "serviceTask_chargeCreditCard", "action": "add", "type": "camunda:inputParameter", "attrs": { "name": "amount" }, "body": "${order.total}" }, + { "op": "add", "kind": "boundaryEvent:error", "name": "Card declined", "id": "event_cardDeclined", "on": "serviceTask_chargeCreditCard", "error": "Card declined", "errorCode": "CARD_DECLINED" }, + { "op": "add", "kind": "userTask", "name": "Inform customer", "id": "userTask_informCustomer", "after": "event_cardDeclined" }, + { "op": "add", "kind": "endEvent", "name": "Payment failed", "id": "endEvent_paymentFailed", "after": "userTask_informCustomer" }, + { "op": "ext", "id": "serviceTask_chargeCreditCard", "action": "add", "type": "camunda:errorEventDefinition", "attrs": { "id": "ErrorMapping_CardDeclined", "errorRef": "error_cardDeclined", "expression": "${declined}" } } ]' | bpmn apply payment.bpmn - --summary bpmn validate payment.bpmn --strict -bpmn ext list payment.bpmn Activity_ChargeCreditCard +bpmn ext list payment.bpmn serviceTask_chargeCreditCard ``` - `--target camunda7` writes the namespaces, the platform version and @@ -154,7 +154,7 @@ bpmn ext list payment.bpmn Activity_ChargeCreditCard without it). - `camunda:inputParameter` is filed into a `camunda:inputOutput` container that is created when missing. -- The error boundary event creates the root error `Error_CardDeclined` (its id +- The error boundary event creates the root error `error_cardDeclined` (its id comes from the name; the result line `created error ...` names it), which the external task's `camunda:errorEventDefinition` references with `errorRef`, so that op comes after the boundary event. @@ -163,30 +163,29 @@ bpmn ext list payment.bpmn Activity_ChargeCreditCard ## 5. Camunda 8: job worker with mappings, headers and a message ```bash -bpmn new shipping.bpmn --name "Shipping" --id Process_Shipping --target camunda8 +bpmn new shipping.bpmn --name "Shipping" --id shipping --target camunda8 printf '%s' '[ - { "op": "add", "kind": "startEvent", "name": "Order paid", "id": "Event_OrderPaid", "in": "Process_Shipping" }, - { "op": "add", "kind": "serviceTask", "name": "Create shipping label", "id": "Activity_CreateShippingLabel", "after": "Event_OrderPaid" }, - { "op": "ext", "id": "Activity_CreateShippingLabel", "action": "add", "type": "zeebe:taskDefinition", "attrs": { "type": "create-shipping-label", "retries": "5" } }, - { "op": "ext", "id": "Activity_CreateShippingLabel", "action": "add", "type": "zeebe:input", "attrs": { "source": "=order.address", "target": "address" } }, - { "op": "ext", "id": "Activity_CreateShippingLabel", "action": "add", "type": "zeebe:output", "attrs": { "source": "=labelId", "target": "shippingLabelId" } }, - { "op": "ext", "id": "Activity_CreateShippingLabel", "action": "add", "type": "zeebe:header", "attrs": { "key": "carrier", "value": "dhl" } }, - { "op": "add", "kind": "receiveTask", "name": "Wait for pickup", "id": "Activity_WaitForPickup", "message": "ParcelPickedUp", "refAs": "$parcelPickedUp", "after": "Activity_CreateShippingLabel" }, + { "op": "add", "kind": "startEvent", "name": "Order paid", "id": "startEvent_orderPaid", "in": "shipping" }, + { "op": "add", "kind": "serviceTask", "name": "Create shipping label", "id": "serviceTask_createShippingLabel", "after": "startEvent_orderPaid" }, + { "op": "ext", "id": "serviceTask_createShippingLabel", "action": "add", "type": "zeebe:taskDefinition", "attrs": { "type": "create-shipping-label", "retries": "5" } }, + { "op": "ext", "id": "serviceTask_createShippingLabel", "action": "add", "type": "zeebe:input", "attrs": { "source": "=order.address", "target": "address" } }, + { "op": "ext", "id": "serviceTask_createShippingLabel", "action": "add", "type": "zeebe:output", "attrs": { "source": "=labelId", "target": "shippingLabelId" } }, + { "op": "ext", "id": "serviceTask_createShippingLabel", "action": "add", "type": "zeebe:header", "attrs": { "key": "carrier", "value": "dhl" } }, + { "op": "add", "kind": "receiveTask", "name": "Wait for pickup", "id": "receiveTask_waitForPickup", "message": "ParcelPickedUp", "refAs": "$parcelPickedUp", "after": "serviceTask_createShippingLabel" }, { "op": "ext", "id": "$parcelPickedUp", "action": "add", "type": "zeebe:subscription", "attrs": { "correlationKey": "=orderId" } }, - { "op": "add", "kind": "endEvent", "name": "Order shipped", "id": "Event_OrderShipped", "after": "Activity_WaitForPickup" }, - { "op": "add", "kind": "boundaryEvent:error", "name": "Address invalid", "id": "Event_AddressInvalid", "on": "Activity_CreateShippingLabel", "error": "Address invalid", "errorCode": "ADDRESS_INVALID" }, - { "op": "add", "kind": "userTask", "name": "Correct address", "id": "Activity_CorrectAddress", "after": "Event_AddressInvalid" }, - { "op": "ext", "id": "Activity_CorrectAddress", "action": "add", "type": "zeebe:assignmentDefinition", "attrs": { "candidateGroups": "logistics" } }, - { "op": "add", "kind": "endEvent", "name": "Shipping stopped", "id": "Event_ShippingStopped", "after": "Activity_CorrectAddress" } + { "op": "add", "kind": "endEvent", "name": "Order shipped", "id": "endEvent_orderShipped", "after": "receiveTask_waitForPickup" }, + { "op": "add", "kind": "boundaryEvent:error", "name": "Address invalid", "id": "event_addressInvalid", "on": "serviceTask_createShippingLabel", "error": "Address invalid", "errorCode": "ADDRESS_INVALID" }, + { "op": "add", "kind": "userTask", "name": "Correct address", "id": "userTask_correctAddress", "after": "event_addressInvalid" }, + { "op": "ext", "id": "userTask_correctAddress", "action": "add", "type": "zeebe:assignmentDefinition", "attrs": { "candidateGroups": "logistics" } }, + { "op": "add", "kind": "endEvent", "name": "Shipping stopped", "id": "endEvent_shippingStopped", "after": "userTask_correctAddress" } ]' | bpmn apply shipping.bpmn - --summary bpmn validate shipping.bpmn --strict -bpmn show shipping.bpmn --around Activity_CreateShippingLabel --depth 1 +bpmn show shipping.bpmn --around serviceTask_createShippingLabel --depth 1 ``` - `zeebe:input` / `zeebe:output` go into `zeebe:ioMapping`, `zeebe:header` into `zeebe:taskHeaders`; an item with the same target / key replaces the old one. -- The receive task creates the message from its name (its id follows the - file's id style); `refAs` names it in the batch, so the subscription's +- The receive task creates the message from its name (`message_`); `refAs` names it in the batch, so the subscription's correlation key goes on that message without guessing its id. - New user tasks of a Camunda 8 file are Camunda user tasks (`zeebe:userTask`). - Details and more Camunda 8 settings: reference/camunda8.md. diff --git a/src/adapters/in/cli/FormatCommand.ts b/src/adapters/in/cli/FormatCommand.ts index 4104508a..ad6ef6a1 100644 --- a/src/adapters/in/cli/FormatCommand.ts +++ b/src/adapters/in/cli/FormatCommand.ts @@ -19,7 +19,7 @@ export abstract class FormatCommand extends MutationCommand { /** The ids and selectors of a format command; `need`: ids or a selector are required. */ protected selectorsOf(ids: string[], options: RawOpts, need: boolean): Pick { const path = options['path'] as string[] | undefined; - assertPathFlags(path, options['via'], { path: 'Put the other ids before --path, or end the list with --: `bpmn color f.bpmn --path Event_Start Event_Done --color green`.' }); + assertPathFlags(path, options['via'], { path: 'Put the other ids before --path, or end the list with --: `bpmn color f.bpmn --path startEvent_start endEvent_done --color green`.' }); const out = { ...(ids.length ? { ids } : {}), ...(path ? { path } : {}), ...(options['via'] ? { via: options['via'] as string[] } : {}), ...this.pick(options, ['kind', 'branch'] as const) }; if (need && !ids.length && !path && !out.kind && !out.branch) throw usageError('name the elements: ids and / or --path , --kind , --branch '); return out; diff --git a/src/adapters/in/cli/NewFileCommand.ts b/src/adapters/in/cli/NewFileCommand.ts index 9b842e05..c942f190 100644 --- a/src/adapters/in/cli/NewFileCommand.ts +++ b/src/adapters/in/cli/NewFileCommand.ts @@ -12,7 +12,7 @@ export class NewFileCommand extends MutationCommand { this.withMutationOptions( this.define(program, 'new') .option('--name ', 'process name') - .option('--id ', "process id (default: Process_ or Process_1; a bpmn:Process prefix the .bpmnlintrc's element-id-naming names: that prefix and the name, process_main without one)") + .option('--id ', "process id (default: the name in the id case, orderToCash, or Process_1; a bpmn:Process prefix the .bpmnlintrc's element-id-naming names: that prefix and the name, process_main without one)") .option('--no-executable', 'isExecutable=false') .option('--target ', 'camunda8 | camunda7: declare vendor namespaces (camunda7: also historyTimeToLive=180 and the platform version, like Camunda Modeler; for CIB seven and Operaton too)', (raw: string) => { try { diff --git a/src/adapters/in/cli/PlaceCommand.ts b/src/adapters/in/cli/PlaceCommand.ts index cdd3b892..f55c0b42 100644 --- a/src/adapters/in/cli/PlaceCommand.ts +++ b/src/adapters/in/cli/PlaceCommand.ts @@ -24,7 +24,7 @@ export class PlaceCommand extends FormatCommand { if (options['side'] !== undefined) { const given = ['rowOf', 'below', 'above', 'columnOf', 'after', 'before', 'path', 'kind', 'branch'].filter((flag) => options[flag] !== undefined); if (given.length) throw usageError(`--side cannot be combined with ${given.map(flag).join(', ')}`, { hint: '`bpmn place --side top` moves a boundary event to the top border of its host; place the host to move both.' }); - if (ids.length !== 1) throw usageError('--side takes exactly one id: the boundary event', { hint: 'Example: `bpmn place order.bpmn Event_PaymentOverdue --side top`.' }); + if (ids.length !== 1) throw usageError('--side takes exactly one id: the boundary event', { hint: 'Example: `bpmn place order.bpmn event_paymentOverdue --side top`.' }); await this.runFormat(file, { op: 'place', ids, side: options['side'] as PlaceShapes['side'] }, options); return; } diff --git a/src/adapters/in/cli/RenderCommand.ts b/src/adapters/in/cli/RenderCommand.ts index b80a51cd..0385fa08 100644 --- a/src/adapters/in/cli/RenderCommand.ts +++ b/src/adapters/in/cli/RenderCommand.ts @@ -45,7 +45,7 @@ export class RenderCommand extends CliCommand { ViewRequest.assertAroundFlags(around, options['depth'] !== undefined ? ['--depth'] : options['inner'] ? ['--inner'] : [], 'render'); const path = options['path'] as string[] | undefined; const via = options['via'] as string[] | undefined; - assertPathFlags(path, via, { path: 'Put element ids before --path, or end the list with --: `bpmn render f.bpmn --path Event_Start Event_Done -o path.png`.', via: 'Example: --path Event_Start Event_Done --via Flow_Yes.' }); + assertPathFlags(path, via, { path: 'Put element ids before --path, or end the list with --: `bpmn render f.bpmn --path startEvent_start endEvent_done -o path.png`.', via: 'Example: --path startEvent_start endEvent_done --via flow_okToBook.' }); const highlight = options['highlight'] as string[] | undefined; const opts: RenderOptions = { maxSize: (options['maxSize'] as number | undefined) ?? RENDER_MAX_SIZE, diff --git a/src/adapters/in/cli/ShowCommand.ts b/src/adapters/in/cli/ShowCommand.ts index 415298c9..57a0e094 100644 --- a/src/adapters/in/cli/ShowCommand.ts +++ b/src/adapters/in/cli/ShowCommand.ts @@ -22,7 +22,7 @@ export class ShowCommand extends CliCommand { .option('--inner', 'with --around: also enter sub-processes from outside') .option('--context', 'with an id: the element in its context (where, lane, before / after, what catches it, message flows)') .option('--layout', 'the drawing instead of the model: rows of node ids per pool / lane with their columns (c0..cN), wide gaps, colours, label sides, layout problems') - .option('--conventions', "the id conventions new ids follow here: element-id-naming of the .bpmnlintrc (prefixes, case, qualifier) and the file's own style; a file that does not exist yet: those of a new model there") + .option('--conventions', "the id conventions new ids follow here: element-id-naming of the .bpmnlintrc (prefixes, case, qualifier), else its defaults, and bpmn-cli's prefixes for the types it does not check; a file that does not exist yet: those of a new model there") .option('--lint-config ', "with --conventions: the project's bpmnlint configuration (default: the nearest .bpmnlintrc above the file, up to the repository root, whoever owns it; outside one only in the file's directory; stdin: from the working directory)") .option('--no-lint-config', 'with --conventions: do not read the .bpmnlintrc (no id conventions from element-id-naming)'), ).action((file: string, id: string | undefined, options: RawOpts) => (options['conventions'] ? this.conventions(file, id, options) : this.view(file, id, options))); diff --git a/src/adapters/in/report/AgentGuide.ts b/src/adapters/in/report/AgentGuide.ts index 43e19193..1f5f77eb 100644 --- a/src/adapters/in/report/AgentGuide.ts +++ b/src/adapters/in/report/AgentGuide.ts @@ -27,7 +27,7 @@ const READING = [ const LOOKING = [ ' bpmn render --problems -o f.png the whole plane; every layout problem of `bpmn metrics` as a numbered', ' badge on the defect, listed below the result line with its ids:', - ' 1 crossings [Flow_6, Flow_21]', + ' 1 crossings [flow_checkToBook, flow_okToFix]', ' bpmn render --around [--depth 2] [--inner] exactly the nodes of `show --around` (cheap: a few', ' hundred image tokens), their flows, data and annotations', ' bpmn render --path [--via ] the nodes and flows of that sequence-flow path', @@ -51,26 +51,26 @@ const CAMUNDA8 = [ ' into their container, single elements are merged, an item with the same key (zeebe:input / output target,', ' zeebe:header key) replaces the old one. FEEL values start with = (write attr==: the first = separates', ' key and value):', - ' bpmn ext add f.bpmn Activity_Charge zeebe:taskDefinition type=charge-card retries=3 job worker', - ' bpmn ext add f.bpmn Activity_Charge zeebe:input source==order.total target=amount -> zeebe:ioMapping', - ' bpmn ext add f.bpmn Activity_Charge zeebe:output source==receipt target=receipt', - ' bpmn ext add f.bpmn Activity_Charge zeebe:header key=channel value=web -> zeebe:taskHeaders', - ' bpmn ext add f.bpmn Activity_Review zeebe:assignmentDefinition candidateGroups=sales assignee==reviewer', - ' bpmn ext add f.bpmn Activity_Review zeebe:formDefinition formId=review-form linked form', - ' bpmn ext add f.bpmn Activity_Bill zeebe:calledElement processId=billing propagateAllChildVariables=false', - ' bpmn ext add f.bpmn Activity_Inform loop.zeebe:loopCharacteristics inputCollection==parties inputElement=party', + ' bpmn ext add f.bpmn serviceTask_charge zeebe:taskDefinition type=charge-card retries=3 job worker', + ' bpmn ext add f.bpmn serviceTask_charge zeebe:input source==order.total target=amount -> zeebe:ioMapping', + ' bpmn ext add f.bpmn serviceTask_charge zeebe:output source==receipt target=receipt', + ' bpmn ext add f.bpmn serviceTask_charge zeebe:header key=channel value=web -> zeebe:taskHeaders', + ' bpmn ext add f.bpmn userTask_review zeebe:assignmentDefinition candidateGroups=sales assignee==reviewer', + ' bpmn ext add f.bpmn userTask_review zeebe:formDefinition formId=review-form linked form', + ' bpmn ext add f.bpmn callActivity_bill zeebe:calledElement processId=billing propagateAllChildVariables=false', + ' bpmn ext add f.bpmn sendTask_inform loop.zeebe:loopCharacteristics inputCollection==parties inputElement=party', ' (creates the parallel multi-instance loop)', - ' bpmn set f.bpmn Activity_Wait message=PaymentReceived receive task', - ' bpmn ext add f.bpmn Message_PaymentReceived zeebe:subscription correlationKey==orderId message correlation', - ' bpmn set f.bpmn Activity_Wait zeebe:correlationKey==orderId the same, on the message Activity_Wait waits for', + ' bpmn set f.bpmn receiveTask_waitForPayment message=PaymentReceived receive task', + ' bpmn ext add f.bpmn message_paymentReceived zeebe:subscription correlationKey==orderId message correlation', + ' bpmn set f.bpmn receiveTask_waitForPayment zeebe:correlationKey==orderId the same, on the message receiveTask_waitForPayment waits for', ' A zeebe:subscription (ext add) or zeebe:correlationKey (set) given to a catch event or receive task goes to the', ' bpmn:Message it waits for (Camunda 8 reads it there; the result notes it). In `apply`, "refAs": "$msg" names the', ' message an op creates or finds: {"op":"add","kind":"receiveTask","name":"Wait","message":"Paid","refAs":"$msg",...},', ' then {"op":"ext","id":"$msg","action":"add","type":"zeebe:subscription","attrs":{"correlationKey":"=orderId"}}.', - ' bpmn set f.bpmn Activity_Rate calledDecision=risk-rating zeebe:calledDecision decisionId (business rule task)', - ' bpmn ext add f.bpmn Activity_Rate zeebe:calledDecision resultVariable=risk', - " bpmn add f.bpmn userTask \"Approve\" --after Gateway_Big --condition '= amount > 1000' --flow-name yes FEEL condition", - " bpmn ext remove f.bpmn Activity_Charge 'zeebe:input[target=amount]'", + ' bpmn set f.bpmn businessRuleTask_rate calledDecision=risk-rating zeebe:calledDecision decisionId (business rule task)', + ' bpmn ext add f.bpmn businessRuleTask_rate zeebe:calledDecision resultVariable=risk', + " bpmn add f.bpmn userTask \"Approve\" --after gateway_big --condition '= amount > 1000' --flow-name yes FEEL condition", + " bpmn ext remove f.bpmn serviceTask_charge 'zeebe:input[target=amount]'", ' New user tasks of a Camunda 8 file get zeebe:userTask (a Camunda user task, like the Modeler); new event', ' definitions an id (Camunda 8.9 refuses conditional, compensation and link catch definitions without one).', ' A zeebe attribute that belongs on an extension element is refused (`set X zeebe:assignee=...`: E_WRONG_HOST,', @@ -94,8 +94,8 @@ const BPMNLINT = [ ' directory is reached through a symbolic link: the path as given first, then the physical directory (where git', ' finds the repository); a linked model file counts by the directory of the link.', ' Findings name the validator, the severity, the rule and the element id (a warning reads `[bpmnlint] `):', - ' [bpmnlint error] label-required Activity_Check: Element is missing label/name (validate)', - ' warning [bpmnlint error] label-required Activity_Check: Element is missing label/name (a write)', + ' [bpmnlint error] label-required userTask_check: Element is missing label/name (validate)', + ' warning [bpmnlint error] label-required userTask_check: Element is missing label/name (a write)', ' validate reports every finding; an error fails the check (exit 2), and three or more of one rule with one hint', ' are one line (its most common message with the ids that have it; --json lists every finding). A write lists', ' only the findings the change added (three or more of one rule and message as one line; --strict exits 5 on', @@ -126,34 +126,32 @@ const BPMNLINT = [ ]; const CONVENTIONS = [ - ' A .bpmnlintrc that runs @miragon/rules/element-id-naming (plugin:@miragon/rules/recommended-for-automation and the', - ' plugin\'s engine defaults turn it on) declares the prefix of each BPMN type, the case of the body and the', - ' event-definition qualifier. New ids take the declared prefix and case where the rule declares one, so a write adds', - ' no element-id-naming finding:', - ' serviceTask_checkInvoice, gateway_approved, event_timer2Days, flow_checkInvoiceToBookInvoice; under camelCase /', - ' PascalCase a taken id gets a glued 2 (serviceTask_checkInvoice2; after a digit No2: serviceTask_step1No2) and a', - ' split\'s join is gateway_approvedJoin', - ' `bpmn show --conventions` lists each pattern with its kinds and where it comes from (lint: the rule, file:', - ' the file\'s ids, lint shape: shaped like the rule\'s prefixes, default: bpmn-cli\'s; not checked: the rule declares', - ' nothing for it), also for a file that does not exist yet: run it before the first change. Where none are declared', - ' it says why (--no-lint-config, no .bpmnlintrc, one that could not be loaded: W_LINT_UNAVAILABLE, the rule off).', - ' What the rule does not name (lanes, pools, data, messages) follows the file, learned without the ids that already', - ' follow the rule; a new file takes the rule\'s case and prefix shape (lane_sales). Message flows take the sequence', - ' flows\' prefix until the file\'s own message flows show a style. The process and the definitions keep', - ' Process_ / Definitions_ unless the rule names bpmn:Process / bpmn:Definitions (process_main', - ' without a name); a new pool\'s process takes the prefix two of the file\'s process ids share (process_customer,', - ' process_shop: process_bankAccount; never the pools\'). eventDefinitionQualifier required: timerStartEvent_...,', - ' messageEvent_...; optional (the default): the plain prefix. Explicit ids are taken as given (the join of a split', - ' whose id lacks the declared prefix or case: gateway_checkJoin). Existing ids never change for the conventions:', - ' after a retype or a trigger change W_ID_PREFIX_STALE names an id whose prefix no longer fits (a batch: from before', - ' it to after it), with the `bpmn set id=` that renames it (the new prefix and the rest of the', - ' id, or the id of what it is now where that rest failed the rule); an element the batch created takes the new', - ' prefix instead, and the flows named after it follow. In a file with ids of its own the rule rejects, a result', - ' names the new ids under a prefix the rule declares that differ from the file\'s own style (`conventions: ...', - ' follow element-id-naming (...)`; JSON: conventions.ids); a new file and one that follows the rule get none.', + ' New ids follow @miragon/rules/element-id-naming, so a write adds no element-id-naming finding. A .bpmnlintrc that', + ' runs it (plugin:@miragon/rules/recommended-for-automation and the plugin\'s engine defaults turn it on) declares', + ' the prefix of each BPMN type, the case of the body (camelCase, PascalCase, snake_case, kebab-case, any) and the', + ' event-definition qualifier; without one (no .bpmnlintrc, --no-lint-config, the rule off) new ids follow the rule\'s', + ' defaults in camelCase, whatever ids the file already has:', + ' serviceTask_checkInvoice, gateway_approved, event_timer2Days, flow_checkInvoiceToBookInvoice, participant_customer,', + ' lane_sales, dataObject_invoice; a taken id gets a glued 2 (serviceTask_checkInvoice2; after a digit No2:', + ' serviceTask_step1No2; snake_case _2, kebab-case -2) and a split\'s join is gateway_approvedJoin', + ' Types the rule does not check take bpmn-cli\'s prefix: the lower camelCase type name (message_, signal_, error_,', + ' laneSet_, definitions_), a backing data object dataObjectItem_; its first letter follows the declared prefixes', + ' (Message_ next to Activity_). A process takes no prefix: a named one is its name (orderToCash, the deployment key),', + ' an unnamed one Process_1, a new pool\'s process the pool\'s name; a declared bpmn:Process prefix wins.', + ' eventDefinitionQualifier required: timerStartEvent_..., messageEvent_...; optional (the default): the plain prefix.', + ' `bpmn show --conventions` lists each pattern with its kinds and where it comes from (lint: the rule,', + ' default: the rule\'s defaults, nothing declared; bpmn-cli: a type the rule does not check), also for a file that', + ' does not exist yet: run it before the first change. Where none are declared it says why (--no-lint-config, no', + ' .bpmnlintrc, one that could not be loaded: W_LINT_UNAVAILABLE, the rule off).', + ' Explicit ids are taken as given (the join of a split whose id lacks the prefix or case: gateway_checkJoin).', + ' Existing ids never change for the conventions: rename one with `bpmn set id=`. After a retype', + ' or a trigger change under declared conventions W_ID_PREFIX_STALE names an id whose prefix no longer fits (a batch:', + ' from before it to after it), with the `bpmn set id=` that renames it (the new prefix and the rest', + ' of the id, or the id of what it is now where that rest failed the rule); an element the batch created takes the', + ' new prefix instead, and the flows named after it follow. Only flows whose id has the current form', + ' (flow_To) follow their ends; an older one (Flow_AToB) keeps its id.', ' W_CONFIG_IGNORED: a value of the rule new ids cannot follow (a prefix that cannot start an id, one for a DI', ' type: DI ids follow their element, an unknown case or qualifier); a write only counts it.', - ' --no-lint-config, no .bpmnlintrc or the rule off: the file\'s style alone.', ]; const OUTPUT = [ @@ -163,8 +161,7 @@ const OUTPUT = [ ' `bpmn validate ` lists them); then the layout block and the file line. A group warning (W_DUPLICATE_NAME)', ' that only shrank is the old one; an apply batch reports the warnings of its final state. The project\'s bpmnlint', ' (see BPMNLINT) is advisory: an error of it the change added is listed among the warnings', - ' (`warning [bpmnlint error] : ...`). `conventions:` names the new ids under a prefix its', - ' element-id-naming declares, where they differ from the file\'s own style (see CONVENTIONS). Where', + ' (`warning [bpmnlint error] : ...`). Where', ' BPMN_KEEP_FLOW_IDS=1 kept the flow ids (no --keep-flow-ids / --no-keep-flow-ids given) and that changed the', ' result (W_FLOW_ID_STALE, or a move put the node\'s old out-flow back), it says so, also with --summary:', ' `note: flow ids kept: BPMN_KEEP_FLOW_IDS=1 (...)` (JSON: notes); an E_NOT_FOUND of an apply batch, in its hint.', @@ -172,7 +169,7 @@ const OUTPUT = [ ' named its old ends: use the new id; with --keep-flow-ids it keeps its id and W_FLOW_ID_STALE names it) and', ' `removed:` ids, one `format #: ...` line per format op, the added warnings, one `warnings:` count line (what', ' is already in the file includes the project\'s bpmnlint findings) and', - ' one layout line (`layout: incremental, score 12 -> 14; added: crossings [Flow_AToB, Flow_CToD]`).', + ' one layout line (`layout: incremental, score 12 -> 14; added: crossings [flow_aToB, flow_cToD]`).', ' --json is compact (one line; --pretty indents it): warnings: {added, resolved, preexistingCount}; with --summary the', ' summary as JSON. Errors go to stderr (`error E_CODE: message` + hint; with --json one JSON line).', ' stdin / stdout: `-` as the file reads the model from stdin: show -, find - , validate -, metrics -,', @@ -222,15 +219,14 @@ export class AgentGuide { ' drawing the engine made and nobody changed, is redrawn by the built-in engine (see LAYOUT MODES).', ` - To change the picture, use the format commands (${DIAGRAM_COMMAND_NAMES.join(', ')}, order of lanes and pools);`, ' they name other elements, never coordinates (see FORMATTING WITHOUT XML).', - ' - Elements are addressed by id only (names may repeat). Ids speak and follow the file: a new id says what it', - ' names (Activity_CheckInvoice; unnamed: kind + place, Gateway_AfterCheckInvoice, Event_TimerOnReview; flows', - ' their ends, Flow_CheckInvoiceToBookInvoice) with the file\'s prefixes, case and flow form (serviceTask_checkStock,', - ' Task_check_stock, flow_aToB; a Camunda Modeler file of Activity_0k3x9qa gets Activity_CheckInvoice). Never a', + ' - Elements are addressed by id only (names may repeat). Ids speak and follow element-id-naming: a new id says', + ' what it names (userTask_checkInvoice; unnamed: kind + place, gateway_afterCheckInvoice, event_timerOnReview;', + ' flows their ends, flow_checkInvoiceToBookInvoice) with the declared prefixes and case, else the rule\'s', + ' defaults (see CONVENTIONS); the ids the file already has do not change that. Never a', ' hash or a running number; umlauts become ae oe ue ss (ø oe, å aa); at most 64 characters, cut at whole words; a', - ' taken id gets _2 (W_ID_SUFFIXED; an unnamed gateway or task says its kind first, Gateway_ParallelAfterCheck,', - ' then the unnamed anchor it follows, Task_AfterCheckGateway). Where the .bpmnlintrc\'s element-id-naming', - ' declares a prefix, it and the declared case decide instead (serviceTask_checkInvoice; see CONVENTIONS).', - ' A flow names an unnamed gateway by its id (Flow_CheckToAfterCheck). A flow whose id names its ends is', + ' taken id gets 2 (W_ID_SUFFIXED; an unnamed gateway or task says its kind first, gateway_parallelAfterCheck,', + ' then the unnamed anchor it follows, task_afterCheckGateway).', + ' A flow names an unnamed gateway by its id (flow_checkToAfterCheck). A flow whose id names its ends is', ' renamed when a splice or bridge changes them (the result says so). Where flow ids are API (generated code,', ' process tests), --keep-flow-ids or BPMN_KEEP_FLOW_IDS=1 keeps them; W_FLOW_ID_STALE then names each flow', ' whose id no longer names its ends, with the `bpmn set` that renames it.', @@ -304,20 +300,20 @@ export class AgentGuide { ' Selectors (place, align, color, tidy): --path [--via ], --kind ,', ' --branch name many elements at once; elements are always named by id.', ' Recipes:', - ' bpmn color f.bpmn Activity_A Flow_AToB Activity_B --color red shapes and flows in red (default removes)', - ' bpmn color f.bpmn --path Event_Start Event_Done --color green the happy path (--via picks a branch)', + ' bpmn color f.bpmn task_a flow_aToB task_b --color red shapes and flows in red (default removes)', + ' bpmn color f.bpmn --path startEvent_start endEvent_done --color green the happy path (--via picks a branch)', ' bpmn align f.bpmn --kind endEvent --axis column every end event in one column (the rightmost)', - ' bpmn place f.bpmn Activity_Fix Event_Fixed --below Activity_Book a branch one row below (rigid group)', - ' bpmn place f.bpmn --branch Flow_OkToFix --below Activity_Book the whole branch of Flow_OkToFix, up to the join', - ' bpmn place f.bpmn Event_Done --row-of Activity_Check same row, x unchanged', - ' bpmn place f.bpmn Event_Overdue --side top a boundary event and its path on top', - ' bpmn order f.bpmn Participant_Org Lane_Boss Lane_Clerk lanes top to bottom', - ' bpmn order f.bpmn Collaboration_Org Participant_Customer pools top to bottom (the customer on top)', - ' bpmn route f.bpmn Flow_Retry --exit bottom --entry bottom a loop below', - ' bpmn space f.bpmn --after Activity_Check room for one more column', - ' bpmn space f.bpmn --after Activity_Check --by -column close (up to) one column of empty space', + ' bpmn place f.bpmn userTask_fix endEvent_fixed --below serviceTask_book a branch one row below (rigid group)', + ' bpmn place f.bpmn --branch flow_okToFix --below serviceTask_book the whole branch of flow_okToFix, up to the join', + ' bpmn place f.bpmn endEvent_done --row-of userTask_check same row, x unchanged', + ' bpmn place f.bpmn event_overdue --side top a boundary event and its path on top', + ' bpmn order f.bpmn participant_org lane_boss lane_clerk lanes top to bottom', + ' bpmn order f.bpmn collaboration_org participant_customer pools top to bottom (the customer on top)', + ' bpmn route f.bpmn flow_fixToCheck --exit bottom --entry bottom a loop below', + ' bpmn space f.bpmn --after userTask_check room for one more column', + ' bpmn space f.bpmn --after userTask_check --by -column close (up to) one column of empty space', ' bpmn compact f.bpmn close every empty row / column, shrink frames', - ' bpmn label f.bpmn Gateway_Ok --side below', + ' bpmn label f.bpmn gateway_ok --side below', ' bpmn tidy f.bpmn remove overlaps (= layout --tidy)', ' In `apply` they are ops as well ({"op": "place", "ids": [...], "below": "..."}): they run in batch order, each', ' after the layout of the ops before it, all or nothing. A node cannot be placed out of its lane: `move --lane` first', @@ -341,17 +337,17 @@ export class AgentGuide { [ ' bpmn new f.bpmn --name "Order" --target camunda7 camunda + modeler namespaces, historyTimeToLive=180, platform 7.24.0', ' Attributes: `set` (or `add ... key=value`) with the camunda: prefix; elements without an id take a prefix:', - ' bpmn set f.bpmn Activity_Charge camunda:type=external camunda:topic=charge-card external task', - ' bpmn set f.bpmn Activity_Review camunda:candidateGroups=sales camunda:formKey=embedded:app:forms/review.html', - " bpmn set f.bpmn Activity_Review 'loop.camunda:collection=${items}' loop.camunda:elementVariable=item collection multi-instance", - ' bpmn set f.bpmn Event_Declined definition.camunda:errorCodeVariable=code on the event definition', - ' bpmn set f.bpmn Flow_Yes condition.camunda:resource=deployment://check.groovy language=groovy script resource condition', + ' bpmn set f.bpmn serviceTask_charge camunda:type=external camunda:topic=charge-card external task', + ' bpmn set f.bpmn userTask_review camunda:candidateGroups=sales camunda:formKey=embedded:app:forms/review.html', + " bpmn set f.bpmn userTask_review 'loop.camunda:collection=${items}' loop.camunda:elementVariable=item collection multi-instance", + ' bpmn set f.bpmn event_declined definition.camunda:errorCodeVariable=code on the event definition', + ' bpmn set f.bpmn flow_okToBook condition.camunda:resource=deployment://check.groovy language=groovy script resource condition', ' Extension elements: `ext add`; child types are filed into their container, single containers merged:', - " bpmn ext add f.bpmn Activity_Charge camunda:inputParameter name=amount --body '${order.total}'", - " bpmn ext add f.bpmn Activity_Charge camunda:errorEventDefinition id=Map_Declined errorRef=Error_Declined 'expression=${declined}'", - ' bpmn ext add f.bpmn Activity_Call camunda:in variables=all', - ' bpmn ext add f.bpmn Activity_Review loop.camunda:failedJobRetryTimeCycle --body R3/PT5M', - " bpmn ext remove f.bpmn Activity_Charge 'camunda:inputParameter[name=amount]'", + " bpmn ext add f.bpmn serviceTask_charge camunda:inputParameter name=amount --body '${order.total}'", + " bpmn ext add f.bpmn serviceTask_charge camunda:errorEventDefinition id=Map_Declined errorRef=error_declined 'expression=${declined}'", + ' bpmn ext add f.bpmn callActivity_call camunda:in variables=all', + ' bpmn ext add f.bpmn userTask_review loop.camunda:failedJobRetryTimeCycle --body R3/PT5M', + " bpmn ext remove f.bpmn serviceTask_charge 'camunda:inputParameter[name=amount]'", ' A camunda attribute on the wrong element is refused (E_WRONG_HOST, the hint names the right key).', " One of several event definitions of an event: 'definition[1].=...' or 'definition[timer].=...' (plain", ' definition. is E_AMBIGUOUS_NESTED); the engines act on one of them only, set trigger= keeps that one.', @@ -420,8 +416,8 @@ export class AgentGuide { [ ' - You edit the model (kinds, names, flows, triggers, lanes, pools, properties, extensions), never the diagram: a', ' hand-made drawing is kept and changes are placed locally; a new file is drawn for you.', - ' - Elements are addressed by id only (names may repeat). New ids speak in the file\'s id conventions (never a hash):', - ' Activity_CheckInvoice, Gateway_AfterCheckInvoice, Flow_CheckInvoiceToBookInvoice; an explicit id (--id, "id")', + ' - Elements are addressed by id only (names may repeat). New ids speak in the project\'s id conventions (never a', + ' hash): userTask_checkInvoice, gateway_afterCheckInvoice, flow_checkInvoiceToBookInvoice; an explicit id (--id, "id")', ' always wins; every result names the ids it created. In apply, "as": "$name" names what an op creates and', ' later ops use "$name" as an id (the result lists alias -> id).', ' - Errors the file already had never block a write; a change that would add one is refused (E_VALIDATION). The', @@ -455,7 +451,7 @@ export class AgentGuide { [ ' --after X append after a gateway / end of a path, else splice into X\'s single outgoing flow', ' --before Y prepend before a join / start of a path, else splice into Y\'s single incoming flow', - ' --flow F splice into flow F (keeps name, condition; Flow_AToB is renamed) --after X --before Y: into X -> Y', + ' --flow F splice into flow F (keeps name, condition; flow_aToB is renamed) --after X --before Y: into X -> Y', ' --in S unconnected in process / sub-process / pool S --on A: boundary event on activity A', ' --to T also connect the new node to T', ].join('\n'), diff --git a/src/adapters/in/report/CommandCatalogue.ts b/src/adapters/in/report/CommandCatalogue.ts index f7ccb44a..ad496deb 100644 --- a/src/adapters/in/report/CommandCatalogue.ts +++ b/src/adapters/in/report/CommandCatalogue.ts @@ -20,30 +20,30 @@ export interface CommandDoc extends CommandSignature { * read differently an entry keeps both: `help` for `--help`, `usage` / `summary` for the guide. */ export const COMMANDS: CommandDoc[] = [ - { name: 'new', arguments: '', help: 'create a new .bpmn file with one empty process', usage: 'bpmn new [--name ] [--id ] [--no-executable] [--target camunda8|camunda7]', summary: 'Create a file with one empty process. --target camunda7 (also for CIB seven and Operaton) writes what Camunda Modeler writes: the camunda / modeler namespaces, the platform version and camunda:historyTimeToLive=180 (the engines refuse an executable process without it). --target camunda8 writes the zeebe / modeler namespaces and the platform (Camunda Cloud, 8.9.0); in a Camunda 8 file new user tasks are Camunda user tasks (zeebe:userTask) and new event definitions get an id, like the Modeler. The process id is Process_ (Process_1 without a name) unless element-id-naming of the .bpmnlintrc names a bpmn:Process prefix (process_orderHandling, process_main without a name; see CONVENTIONS).', examples: ['bpmn new order.bpmn --name "Order handling" --target camunda8', 'bpmn new order.bpmn --name "Order handling" --target camunda7'] }, - { name: 'show', arguments: ' [id]', help: 'print the model (or one element) as the AI sees it: no coordinates (file - = stdin)', usage: 'bpmn show [ [--context]] [--json] [--scope ] | bpmn show --around [--depth ] [--inner] [--json] | bpmn show --layout [--json] | bpmn show --conventions [--json] [--lint-config | --no-lint-config]', summary: 'Print the model in flow order (no coordinates), or every detail of one element. Every node carries its lane (lane=); vendor attributes appear with their values next to the properties (nested ones under their set keys: loop.camunda:collection=${items}), extension types as `ext: camunda:taskListener x3`; the process line carries the process\'s own; a business rule task shows its decision link as calledDecision= in every spelling; message flows name their endpoints, the collaboration lists its annotations. --around prints only the neighbourhood of an element (--depth flow steps both ways, default 2; see READING LARGE MODELS), --context the element in its context (where, lane, before / after, catching events, message flows). --layout prints the drawing instead: per pool / lane the rows of node ids (left to right) with their column across the diagram (c0..cN; " … " where a wide gap splits a row), the wide empty gaps between columns, colours, labels off their default side and the layout problems with ids. --conventions prints the id conventions new ids follow in the file: per pattern the kinds and where it comes from (element-id-naming of the .bpmnlintrc, the file\'s ids, bpmn-cli\'s default), also for a file that does not exist yet (see CONVENTIONS). `-` as the file reads stdin.', examples: ['bpmn show order.bpmn', 'bpmn show order.bpmn --around Activity_CheckInvoice', 'bpmn show order.bpmn Activity_CheckInvoice --context', 'bpmn show order.bpmn Activity_CheckInvoice --json', 'bpmn show order.bpmn --layout', 'bpmn show order.bpmn --conventions'] }, + { name: 'new', arguments: '', help: 'create a new .bpmn file with one empty process', usage: 'bpmn new [--name ] [--id ] [--no-executable] [--target camunda8|camunda7]', summary: 'Create a file with one empty process. --target camunda7 (also for CIB seven and Operaton) writes what Camunda Modeler writes: the camunda / modeler namespaces, the platform version and camunda:historyTimeToLive=180 (the engines refuse an executable process without it). --target camunda8 writes the zeebe / modeler namespaces and the platform (Camunda Cloud, 8.9.0); in a Camunda 8 file new user tasks are Camunda user tasks (zeebe:userTask) and new event definitions get an id, like the Modeler. The process id is the name in the id case (orderHandling; Process_1 without a name) unless element-id-naming of the .bpmnlintrc names a bpmn:Process prefix (process_orderHandling, process_main without a name; see CONVENTIONS).', examples: ['bpmn new order.bpmn --name "Order handling" --target camunda8', 'bpmn new order.bpmn --name "Order handling" --target camunda7'] }, + { name: 'show', arguments: ' [id]', help: 'print the model (or one element) as the AI sees it: no coordinates (file - = stdin)', usage: 'bpmn show [ [--context]] [--json] [--scope ] | bpmn show --around [--depth ] [--inner] [--json] | bpmn show --layout [--json] | bpmn show --conventions [--json] [--lint-config | --no-lint-config]', summary: 'Print the model in flow order (no coordinates), or every detail of one element. Every node carries its lane (lane=); vendor attributes appear with their values next to the properties (nested ones under their set keys: loop.camunda:collection=${items}), extension types as `ext: camunda:taskListener x3`; the process line carries the process\'s own; a business rule task shows its decision link as calledDecision= in every spelling; message flows name their endpoints, the collaboration lists its annotations. --around prints only the neighbourhood of an element (--depth flow steps both ways, default 2; see READING LARGE MODELS), --context the element in its context (where, lane, before / after, catching events, message flows). --layout prints the drawing instead: per pool / lane the rows of node ids (left to right) with their column across the diagram (c0..cN; " … " where a wide gap splits a row), the wide empty gaps between columns, colours, labels off their default side and the layout problems with ids. --conventions prints the id conventions new ids follow in the file: per pattern the kinds and where it comes from (lint: element-id-naming of the .bpmnlintrc, default: the rule\'s defaults, nothing declared, bpmn-cli: a type the rule does not check), also for a file that does not exist yet (see CONVENTIONS). `-` as the file reads stdin.', examples: ['bpmn show order.bpmn', 'bpmn show order.bpmn --around userTask_checkInvoice', 'bpmn show order.bpmn userTask_checkInvoice --context', 'bpmn show order.bpmn userTask_checkInvoice --json', 'bpmn show order.bpmn --layout', 'bpmn show order.bpmn --conventions'] }, { name: 'find', arguments: ' ', help: 'find elements by id, name or vendor attribute value (case-insensitive substring; file - = stdin)', usage: 'bpmn find [--kind ] [--json]', summary: 'Find elements by id or name substring, or by a vendor attribute value (topic, assignee, candidate groups, listener class, extension attributes and bodies; the hit shows the match); --kind accepts any kind plus sequenceFlow, messageFlow, association, dataAssociation, process, collaboration, message, error, signal, escalation.', examples: ['bpmn find order.bpmn invoice --kind userTask', 'bpmn find order.bpmn charge-card'] }, - { name: 'add', arguments: ' [name] [keyValues...]', help: 'create an element and wire it in (kind[:trigger], e.g. userTask, startEvent:message, boundaryEvent:timer)', usage: 'bpmn add [] [--id ] [placement] [--to ] [--lane ] [flow options] [trigger options] [--collapsed] [--if-absent] [--doc ] [--text ] [--process ] [--black-box] [--members ] [key=value ...]', summary: 'Create one element and wire it in (see PLACEMENT and TRIGGERS). Pools: the first participant wraps the existing process, later ones get a new process; --black-box creates a pool without a process and never wraps, so add a normal pool first.', examples: ['bpmn add order.bpmn start "Order received"', 'bpmn add order.bpmn userTask "Check invoice" --after Event_OrderReceived', 'bpmn add order.bpmn boundary:timer "2 days" --on Activity_CheckInvoice --timer PT2D --non-interrupting', 'bpmn add order.bpmn participant "Order handling"', 'bpmn add order.bpmn participant "Customer" --black-box'] }, - { name: 'connect', arguments: ' ', help: 'connect two elements (sequence flow, message flow, association or data association is inferred)', usage: 'bpmn connect [--name ] [--id ] [--condition ] [--language ] [--default] [--message ] [--if-absent]', summary: 'Connect two elements; sequence flow, message flow, association or data association is inferred from the endpoints.', examples: ["bpmn connect order.bpmn Gateway_InvoiceOk Activity_BookInvoice --name yes --condition '${ok}'", 'bpmn connect order.bpmn Activity_SendOffer Participant_Customer --message Offer'] }, - { name: 'set', arguments: ' [keyValues...]', help: 'set properties: key=value ... (see `bpmn kinds` for keys; key= unsets)', usage: 'bpmn set [--unset ]...', summary: 'Set properties (name, doc, condition, timer, loop, camunda:assignee, ...); key= or --unset removes. Nested elements without an id take a prefix: definition. (event definition), loop. (loop characteristics), condition. (condition expression); see NESTED KEYS in `bpmn kinds`. calledDecision= links a business rule task to its decision in the file\'s spelling (Camunda 7: camunda:decisionRef, Camunda 8: zeebe:calledDecision, no engine: bpmn-cli\'s unprefixed calledDecision; with the library\'s platform \'none\' a link in an engine spelling keeps it and a new link takes the engine spelling all other links share).', examples: ['bpmn set order.bpmn Activity_CheckInvoice name="Check the invoice" camunda:assignee=kermit', 'bpmn set order.bpmn Flow_InvoiceOkToBookInvoice condition=\'${amount > 100}\'', 'bpmn set order.bpmn Activity_CheckInvoice --unset doc', "bpmn set order.bpmn Activity_CheckInvoice 'loop.camunda:collection=${invoices}' loop.camunda:elementVariable=invoice", 'bpmn set order.bpmn Activity_RateRisk calledDecision=risk-rating'] }, - { name: 'remove', arguments: ' ', aliases: ['rm'], help: 'remove elements (connected flows, boundary events, associations follow; a node with one in/out flow is bridged, a merge from every predecessor; a parallel / inclusive join needs --bridge-all or --no-bridge)', usage: 'bpmn remove [--no-bridge | --bridge-all | --with-branch] [--if-exists]', summary: 'Remove elements with cascade; a node with one in / one out is bridged (predecessor -> successor) unless --no-bridge, a merge (several in, one out) from every predecessor; a parallel / inclusive join is refused (E_AMBIGUOUS_BRIDGE: say --bridge-all or --no-bridge). --bridge-all bridges a join from every predecessor to its successor; --with-branch also removes the exclusive downstream path of a node or boundary event (up to the next merge with another path, or the ends).', examples: ['bpmn remove order.bpmn Activity_Old', 'bpmn remove order.bpmn Event_Timeout Flow_ReminderToRemindCustomer --if-exists', 'bpmn remove order.bpmn Event_Reminder --with-branch', 'bpmn remove order.bpmn Gateway_InvoiceOk_join --bridge-all'] }, - { name: 'retype', arguments: ' ', aliases: ['replace'], help: 'change the kind of an element (task -> userTask, endEvent -> endEvent:error ...) keeping id and connections', usage: 'bpmn retype [trigger options]', summary: 'Change the kind (task <-> task/subProcess/callActivity, gateway <-> gateway, event <-> event of the same position, trigger changes) keeping id, name, flows and extensions. A kind without trigger suffix keeps the current trigger; :none (or `set trigger=none`) drops it. Same trigger, new details: the event definition (id, vendor attributes) is kept. A sub-process with content becomes a task / callActivity only with --force (E_WOULD_DROP_CONTENT).', examples: ['bpmn retype order.bpmn Activity_CheckInvoice serviceTask', 'bpmn retype order.bpmn Event_Start startEvent:message --message OrderReceived', 'bpmn retype order.bpmn Event_Start startEvent:none'] }, - { name: 'move', arguments: ' ', help: 'relocate nodes: --after/--before/--flow (re-insert), --in (another scope), --on (boundary events), --lane', usage: 'bpmn move [--after ] [--before ] [--flow ] [--in ] [--on ] [--lane ]', summary: 'Relocate nodes (they are detached with bridging, then placed again) and/or assign a lane. Several ids form a chain: the first takes the placement, each next one goes after the one before; the node it starts after must lie outside it (E_INVALID_PLACEMENT). Nodes already where the placement puts them stay: nothing changes, a note says so.', examples: ['bpmn move order.bpmn Activity_Clarify --after Activity_CheckInvoice', 'bpmn move order.bpmn Activity_Book Activity_Ship --lane Lane_Backoffice'] }, - { name: 'order', arguments: ' ', help: 'order the outgoing flows of a node (top-to-bottom branch order), the lanes of a pool / process / parent lane, or the pools of a collaboration (top to bottom)', usage: 'bpmn order | bpmn order | bpmn order ', summary: 'Set the declaration order of a node\'s outgoing flows. In the diagram the default flow (else the first flow) continues straight and the others alternate below/above it in this order (see PLACEMENT). With lane ids: the lanes of a pool / process / parent lane top to bottom (laneSet and diagram bands). With a collaboration and participant ids: the pools top to bottom (black boxes too; the bands move with their content, message flows are routed again).', examples: ['bpmn order order.bpmn Gateway_InvoiceOk Flow_InvoiceOkToClarifyInvoice Flow_InvoiceOkToBookInvoice', 'bpmn order order.bpmn Participant_OrderHandling Lane_Backoffice Lane_Sales', 'bpmn order order.bpmn Collaboration_OrderHandling Participant_Customer'] }, - { name: 'ext', help: 'vendor extension elements ()', subcommands: [{ name: 'add', arguments: ' [attrs...]', help: 'add an extension element, e.g. zeebe:taskDefinition type=send-email retries=3; child types go into their container (camunda:inputParameter -> camunda:inputOutput), single-instance containers are merged; may be a path (camunda:connector/camunda:inputParameter) and take a definition. / loop. / condition. prefix for a nested element' }, { name: 'remove', arguments: ' ', help: "remove extension elements: a type, one item ('camunda:inputParameter[name=x]', 'camunda:formField[1]'), a path, or an index from `ext list` (loop.0 for a nested element's)" }, { name: 'list', arguments: ' ', help: 'list extension elements of an element (and of its event definition / loop / condition) as an indented tree' }], usage: 'bpmn ext add [attr=value ...] [--body ] [--xml ] [--replace] | bpmn ext remove | bpmn ext list [--json]', summary: 'Vendor extension elements inside (sub-command first, then file and element id). Child types go into their container (camunda:inputParameter -> camunda:inputOutput, camunda:formField -> camunda:formData, zeebe:input -> zeebe:ioMapping), single-instance containers are merged (E_DUPLICATE_EXTENSION on a conflict; --replace replaces), an item with the same key (name / id / target) is replaced. A path reaches nested containers (camunda:connector/camunda:inputParameter); remove takes a selector (\'camunda:inputParameter[name=x]\', \'camunda:formField[1]\'). A definition. / loop. / condition. prefix addresses the nested element (loop.camunda:failedJobRetryTimeCycle). Quote selectors in the shell.', examples: ['bpmn ext add order.bpmn Activity_BookInvoice zeebe:taskDefinition type=book-invoice retries=3', 'bpmn ext add order.bpmn Activity_BookInvoice zeebe:ioMapping --xml \'\'', "bpmn ext add order.bpmn Activity_ChargeCard camunda:inputParameter name=amount --body '${order.total}'", "bpmn ext remove order.bpmn Activity_ChargeCard 'camunda:inputParameter[name=amount]'", 'bpmn ext list order.bpmn Activity_BookInvoice', 'bpmn ext remove order.bpmn Activity_BookInvoice zeebe:taskDefinition'] }, - { name: 'apply', arguments: ' [ops]', help: 'apply a JSON list of operations in one transaction (ops file or - for stdin; file - = the model from stdin, the result to stdout)', usage: 'bpmn apply [ | -]', summary: 'Run a list of ops (JSON) as one transaction: all or nothing, one layout at the end, then the format ops in batch order. An op names what it creates with "as": "$name"; later ops use "$name" as an id. `-` as the ops reads them from stdin; `-` as the file reads the model from stdin (the ops then come from a file) and writes the result to stdout.', examples: ['bpmn apply order.bpmn ops.json', "echo '[{\"op\":\"add\",\"kind\":\"userTask\",\"name\":\"Ship\",\"after\":\"Activity_BookInvoice\"}]' | bpmn apply order.bpmn -", 'cat order.bpmn | bpmn apply - ops.json > new.bpmn'] }, - { name: 'place', arguments: ' [ids...]', help: 'diagram only: move shapes (one rigid group, the first id is the reference) to the row and/or column of another element', usage: 'bpmn place [] [--row-of | --below | --above ] [--column-of | --after | --before ] [SELECTORS] | bpmn place --side top|bottom', summary: 'Diagram only: move shapes as one rigid group so the first lands on the row and/or column of another element; boundary events and labels follow, others give way, flows are rerouted. --branch moves what only that branch reaches up to the join. Refuses to leave the lane / pool / sub-process (E_LEAVES_CONTAINER); E_NO_ROOM names a checked `space` command that makes the room. --side moves a boundary event to the top / bottom border of its host, its exception path (drawn on the old side, in the host\'s lane / pool / sub-process) follows.', examples: ['bpmn place order.bpmn Activity_ClarifyInvoice Event_ReminderSent --below Activity_BookInvoice', 'bpmn place order.bpmn Event_InvoiceHandled --row-of Activity_CheckInvoice --after Gateway_InvoiceOk_join', 'bpmn place order.bpmn --branch Flow_InvoiceOkToClarifyInvoice --below Activity_BookInvoice', 'bpmn place order.bpmn Event_ApprovalOverdue --side top'] }, - { name: 'align', arguments: ' [ids...]', help: 'diagram only: put shapes on one row (same vertical centre) or one column (same horizontal centre)', usage: 'bpmn align [] --axis row|column [--to ] [SELECTORS]', summary: 'Diagram only: put shapes on one row (same vertical centre) or one column (same horizontal centre) as --to (default: the first id; with only --kind and --axis column the rightmost one).', examples: ['bpmn align order.bpmn Event_InvoiceHandled Event_ReminderSent --axis column', 'bpmn align order.bpmn --kind endEvent --axis column'] }, - { name: 'color', arguments: ' [ids...]', aliases: ['colour'], help: 'diagram only: colour shapes and connections (bpmn-js colour picker colours; default removes the colour)', usage: `bpmn color [] --color ${Object.keys(SWATCHES).join('|')}|default [SELECTORS]`, summary: 'Diagram only: colour shapes and connections with a bpmn-js colour picker colour; default removes it. Colours survive every later write. SELECTORS (place, align, color, tidy): --path [--via ] (every node and flow on the shortest sequence-flow path), --kind (every element of a kind), --branch (what only that branch reaches up to the join); they add to the ids.', examples: ['bpmn color order.bpmn Activity_CheckInvoice Flow_CheckInvoiceToInvoiceOk Gateway_InvoiceOk --color red', 'bpmn color order.bpmn --path Event_OrderReceived Event_Done --color green'] }, - { name: 'label', arguments: ' ', help: 'diagram only: put the external label of an event, gateway, data element or flow on one side', usage: 'bpmn label --side above|below|left|right', summary: 'Diagram only: put the external label of an event, gateway, data object / store or flow on one side: a shape\'s label off lines, other labels and the name of an expanded sub-process where possible (above a shape at the top of a sub-process, the sub-process makes room below its name first); a flow\'s label at the middle of its longest horizontal / vertical segment, unchecked.', examples: ['bpmn label order.bpmn Gateway_InvoiceOk --side below'] }, - { name: 'route', arguments: ' ', help: 'diagram only: route a sequence / message flow again, optionally forcing the side it leaves and enters by (a flow without a line whose ends are drawn gets one)', usage: 'bpmn route [--exit right|top|bottom|left] [--entry left|top|bottom|right]', summary: 'Diagram only: route one sequence / message flow again, optionally forcing the side it leaves its source and enters its target by (a flow without a line gets one).', examples: ['bpmn route order.bpmn Flow_ReminderToRemindCustomer --exit bottom --entry bottom'] }, - { name: 'space', arguments: '', help: 'diagram only: insert space right of / below an element (the modeler\'s space tool)', usage: 'bpmn space (--after | --below ) [--by column|row|col|row||-column|-col|-row|-]', summary: 'Diagram only: the modeler\'s space tool: everything right of (--after, within the pool) or below (--below) the element moves by one column / row (default), col / row of them, or (a number, 80px; --by 2 is 2 px, --by 2col two columns; the result notes the distance); frames grow. On a lane or pool it makes that frame bigger. A negative amount closes up to that much empty space there instead (only as far as it is empty; on a lane / pool: its own bottom / right).', examples: ['bpmn space order.bpmn --after Activity_CheckInvoice', 'bpmn space order.bpmn --below Lane_Sales --by 80', 'bpmn space order.bpmn --after Activity_CheckInvoice --by 2col', 'bpmn space order.bpmn --after Activity_CheckInvoice --by -column'] }, + { name: 'add', arguments: ' [name] [keyValues...]', help: 'create an element and wire it in (kind[:trigger], e.g. userTask, startEvent:message, boundaryEvent:timer)', usage: 'bpmn add [] [--id ] [placement] [--to ] [--lane ] [flow options] [trigger options] [--collapsed] [--if-absent] [--doc ] [--text ] [--process ] [--black-box] [--members ] [key=value ...]', summary: 'Create one element and wire it in (see PLACEMENT and TRIGGERS). Pools: the first participant wraps the existing process, later ones get a new process; --black-box creates a pool without a process and never wraps, so add a normal pool first.', examples: ['bpmn add order.bpmn start "Order received"', 'bpmn add order.bpmn userTask "Check invoice" --after startEvent_orderReceived', 'bpmn add order.bpmn boundary:timer "2 days" --on userTask_checkInvoice --timer PT2D --non-interrupting', 'bpmn add order.bpmn participant "Order handling"', 'bpmn add order.bpmn participant "Customer" --black-box'] }, + { name: 'connect', arguments: ' ', help: 'connect two elements (sequence flow, message flow, association or data association is inferred)', usage: 'bpmn connect [--name ] [--id ] [--condition ] [--language ] [--default] [--message ] [--if-absent]', summary: 'Connect two elements; sequence flow, message flow, association or data association is inferred from the endpoints.', examples: ["bpmn connect order.bpmn gateway_invoiceOk serviceTask_bookInvoice --name yes --condition '${ok}'", 'bpmn connect order.bpmn sendTask_sendOffer participant_customer --message Offer'] }, + { name: 'set', arguments: ' [keyValues...]', help: 'set properties: key=value ... (see `bpmn kinds` for keys; key= unsets)', usage: 'bpmn set [--unset ]...', summary: 'Set properties (name, doc, condition, timer, loop, camunda:assignee, ...); key= or --unset removes. Nested elements without an id take a prefix: definition. (event definition), loop. (loop characteristics), condition. (condition expression); see NESTED KEYS in `bpmn kinds`. calledDecision= links a business rule task to its decision in the file\'s spelling (Camunda 7: camunda:decisionRef, Camunda 8: zeebe:calledDecision, no engine: bpmn-cli\'s unprefixed calledDecision; with the library\'s platform \'none\' a link in an engine spelling keeps it and a new link takes the engine spelling all other links share).', examples: ['bpmn set order.bpmn userTask_checkInvoice name="Check the invoice" camunda:assignee=kermit', 'bpmn set order.bpmn flow_invoiceOkToBookInvoice condition=\'${amount > 100}\'', 'bpmn set order.bpmn userTask_checkInvoice --unset doc', "bpmn set order.bpmn userTask_checkInvoice 'loop.camunda:collection=${invoices}' loop.camunda:elementVariable=invoice", 'bpmn set order.bpmn businessRuleTask_rateRisk calledDecision=risk-rating'] }, + { name: 'remove', arguments: ' ', aliases: ['rm'], help: 'remove elements (connected flows, boundary events, associations follow; a node with one in/out flow is bridged, a merge from every predecessor; a parallel / inclusive join needs --bridge-all or --no-bridge)', usage: 'bpmn remove [--no-bridge | --bridge-all | --with-branch] [--if-exists]', summary: 'Remove elements with cascade; a node with one in / one out is bridged (predecessor -> successor) unless --no-bridge, a merge (several in, one out) from every predecessor; a parallel / inclusive join is refused (E_AMBIGUOUS_BRIDGE: say --bridge-all or --no-bridge). --bridge-all bridges a join from every predecessor to its successor; --with-branch also removes the exclusive downstream path of a node or boundary event (up to the next merge with another path, or the ends).', examples: ['bpmn remove order.bpmn task_old', 'bpmn remove order.bpmn event_timeout flow_reminderToRemindCustomer --if-exists', 'bpmn remove order.bpmn event_reminder --with-branch', 'bpmn remove order.bpmn gateway_invoiceOkJoin --bridge-all'] }, + { name: 'retype', arguments: ' ', aliases: ['replace'], help: 'change the kind of an element (task -> userTask, endEvent -> endEvent:error ...) keeping id and connections', usage: 'bpmn retype [trigger options]', summary: 'Change the kind (task <-> task/subProcess/callActivity, gateway <-> gateway, event <-> event of the same position, trigger changes) keeping id, name, flows and extensions. A kind without trigger suffix keeps the current trigger; :none (or `set trigger=none`) drops it. Same trigger, new details: the event definition (id, vendor attributes) is kept. A sub-process with content becomes a task / callActivity only with --force (E_WOULD_DROP_CONTENT).', examples: ['bpmn retype order.bpmn userTask_checkInvoice serviceTask', 'bpmn retype order.bpmn startEvent_orderReceived startEvent:message --message OrderReceived', 'bpmn retype order.bpmn startEvent_orderReceived startEvent:none'] }, + { name: 'move', arguments: ' ', help: 'relocate nodes: --after/--before/--flow (re-insert), --in (another scope), --on (boundary events), --lane', usage: 'bpmn move [--after ] [--before ] [--flow ] [--in ] [--on ] [--lane ]', summary: 'Relocate nodes (they are detached with bridging, then placed again) and/or assign a lane. Several ids form a chain: the first takes the placement, each next one goes after the one before; the node it starts after must lie outside it (E_INVALID_PLACEMENT). Nodes already where the placement puts them stay: nothing changes, a note says so.', examples: ['bpmn move order.bpmn userTask_clarifyInvoice --after userTask_checkInvoice', 'bpmn move order.bpmn serviceTask_bookInvoice userTask_ship --lane lane_backoffice'] }, + { name: 'order', arguments: ' ', help: 'order the outgoing flows of a node (top-to-bottom branch order), the lanes of a pool / process / parent lane, or the pools of a collaboration (top to bottom)', usage: 'bpmn order | bpmn order | bpmn order ', summary: 'Set the declaration order of a node\'s outgoing flows. In the diagram the default flow (else the first flow) continues straight and the others alternate below/above it in this order (see PLACEMENT). With lane ids: the lanes of a pool / process / parent lane top to bottom (laneSet and diagram bands). With a collaboration and participant ids: the pools top to bottom (black boxes too; the bands move with their content, message flows are routed again).', examples: ['bpmn order order.bpmn gateway_invoiceOk flow_invoiceOkToClarifyInvoice flow_invoiceOkToBookInvoice', 'bpmn order order.bpmn participant_orderHandling lane_backoffice lane_sales', 'bpmn order order.bpmn collaboration_orderHandling participant_customer'] }, + { name: 'ext', help: 'vendor extension elements ()', subcommands: [{ name: 'add', arguments: ' [attrs...]', help: 'add an extension element, e.g. zeebe:taskDefinition type=send-email retries=3; child types go into their container (camunda:inputParameter -> camunda:inputOutput), single-instance containers are merged; may be a path (camunda:connector/camunda:inputParameter) and take a definition. / loop. / condition. prefix for a nested element' }, { name: 'remove', arguments: ' ', help: "remove extension elements: a type, one item ('camunda:inputParameter[name=x]', 'camunda:formField[1]'), a path, or an index from `ext list` (loop.0 for a nested element's)" }, { name: 'list', arguments: ' ', help: 'list extension elements of an element (and of its event definition / loop / condition) as an indented tree' }], usage: 'bpmn ext add [attr=value ...] [--body ] [--xml ] [--replace] | bpmn ext remove | bpmn ext list [--json]', summary: 'Vendor extension elements inside (sub-command first, then file and element id). Child types go into their container (camunda:inputParameter -> camunda:inputOutput, camunda:formField -> camunda:formData, zeebe:input -> zeebe:ioMapping), single-instance containers are merged (E_DUPLICATE_EXTENSION on a conflict; --replace replaces), an item with the same key (name / id / target) is replaced. A path reaches nested containers (camunda:connector/camunda:inputParameter); remove takes a selector (\'camunda:inputParameter[name=x]\', \'camunda:formField[1]\'). A definition. / loop. / condition. prefix addresses the nested element (loop.camunda:failedJobRetryTimeCycle). Quote selectors in the shell.', examples: ['bpmn ext add order.bpmn serviceTask_bookInvoice zeebe:taskDefinition type=book-invoice retries=3', 'bpmn ext add order.bpmn serviceTask_bookInvoice zeebe:ioMapping --xml \'\'', "bpmn ext add order.bpmn serviceTask_chargeCard camunda:inputParameter name=amount --body '${order.total}'", "bpmn ext remove order.bpmn serviceTask_chargeCard 'camunda:inputParameter[name=amount]'", 'bpmn ext list order.bpmn serviceTask_bookInvoice', 'bpmn ext remove order.bpmn serviceTask_bookInvoice zeebe:taskDefinition'] }, + { name: 'apply', arguments: ' [ops]', help: 'apply a JSON list of operations in one transaction (ops file or - for stdin; file - = the model from stdin, the result to stdout)', usage: 'bpmn apply [ | -]', summary: 'Run a list of ops (JSON) as one transaction: all or nothing, one layout at the end, then the format ops in batch order. An op names what it creates with "as": "$name"; later ops use "$name" as an id. `-` as the ops reads them from stdin; `-` as the file reads the model from stdin (the ops then come from a file) and writes the result to stdout.', examples: ['bpmn apply order.bpmn ops.json', "echo '[{\"op\":\"add\",\"kind\":\"userTask\",\"name\":\"Ship\",\"after\":\"serviceTask_bookInvoice\"}]' | bpmn apply order.bpmn -", 'cat order.bpmn | bpmn apply - ops.json > new.bpmn'] }, + { name: 'place', arguments: ' [ids...]', help: 'diagram only: move shapes (one rigid group, the first id is the reference) to the row and/or column of another element', usage: 'bpmn place [] [--row-of | --below | --above ] [--column-of | --after | --before ] [SELECTORS] | bpmn place --side top|bottom', summary: 'Diagram only: move shapes as one rigid group so the first lands on the row and/or column of another element; boundary events and labels follow, others give way, flows are rerouted. --branch moves what only that branch reaches up to the join. Refuses to leave the lane / pool / sub-process (E_LEAVES_CONTAINER); E_NO_ROOM names a checked `space` command that makes the room. --side moves a boundary event to the top / bottom border of its host, its exception path (drawn on the old side, in the host\'s lane / pool / sub-process) follows.', examples: ['bpmn place order.bpmn userTask_clarifyInvoice endEvent_reminderSent --below serviceTask_bookInvoice', 'bpmn place order.bpmn endEvent_invoiceHandled --row-of userTask_checkInvoice --after gateway_invoiceOkJoin', 'bpmn place order.bpmn --branch flow_invoiceOkToClarifyInvoice --below serviceTask_bookInvoice', 'bpmn place order.bpmn event_approvalOverdue --side top'] }, + { name: 'align', arguments: ' [ids...]', help: 'diagram only: put shapes on one row (same vertical centre) or one column (same horizontal centre)', usage: 'bpmn align [] --axis row|column [--to ] [SELECTORS]', summary: 'Diagram only: put shapes on one row (same vertical centre) or one column (same horizontal centre) as --to (default: the first id; with only --kind and --axis column the rightmost one).', examples: ['bpmn align order.bpmn endEvent_invoiceHandled endEvent_reminderSent --axis column', 'bpmn align order.bpmn --kind endEvent --axis column'] }, + { name: 'color', arguments: ' [ids...]', aliases: ['colour'], help: 'diagram only: colour shapes and connections (bpmn-js colour picker colours; default removes the colour)', usage: `bpmn color [] --color ${Object.keys(SWATCHES).join('|')}|default [SELECTORS]`, summary: 'Diagram only: colour shapes and connections with a bpmn-js colour picker colour; default removes it. Colours survive every later write. SELECTORS (place, align, color, tidy): --path [--via ] (every node and flow on the shortest sequence-flow path), --kind (every element of a kind), --branch (what only that branch reaches up to the join); they add to the ids.', examples: ['bpmn color order.bpmn userTask_checkInvoice flow_checkInvoiceToInvoiceOk gateway_invoiceOk --color red', 'bpmn color order.bpmn --path startEvent_orderReceived endEvent_done --color green'] }, + { name: 'label', arguments: ' ', help: 'diagram only: put the external label of an event, gateway, data element or flow on one side', usage: 'bpmn label --side above|below|left|right', summary: 'Diagram only: put the external label of an event, gateway, data object / store or flow on one side: a shape\'s label off lines, other labels and the name of an expanded sub-process where possible (above a shape at the top of a sub-process, the sub-process makes room below its name first); a flow\'s label at the middle of its longest horizontal / vertical segment, unchecked.', examples: ['bpmn label order.bpmn gateway_invoiceOk --side below'] }, + { name: 'route', arguments: ' ', help: 'diagram only: route a sequence / message flow again, optionally forcing the side it leaves and enters by (a flow without a line whose ends are drawn gets one)', usage: 'bpmn route [--exit right|top|bottom|left] [--entry left|top|bottom|right]', summary: 'Diagram only: route one sequence / message flow again, optionally forcing the side it leaves its source and enters its target by (a flow without a line gets one).', examples: ['bpmn route order.bpmn flow_reminderToRemindCustomer --exit bottom --entry bottom'] }, + { name: 'space', arguments: '', help: 'diagram only: insert space right of / below an element (the modeler\'s space tool)', usage: 'bpmn space (--after | --below ) [--by column|row|col|row||-column|-col|-row|-]', summary: 'Diagram only: the modeler\'s space tool: everything right of (--after, within the pool) or below (--below) the element moves by one column / row (default), col / row of them, or (a number, 80px; --by 2 is 2 px, --by 2col two columns; the result notes the distance); frames grow. On a lane or pool it makes that frame bigger. A negative amount closes up to that much empty space there instead (only as far as it is empty; on a lane / pool: its own bottom / right).', examples: ['bpmn space order.bpmn --after userTask_checkInvoice', 'bpmn space order.bpmn --below lane_sales --by 80', 'bpmn space order.bpmn --after userTask_checkInvoice --by 2col', 'bpmn space order.bpmn --after userTask_checkInvoice --by -column'] }, { name: 'tidy', arguments: ' [ids...]', help: 'diagram only: remove overlaps and gaps < 20 px with minimal moves, keeping the order (default: every shape)', usage: 'bpmn tidy [...] [SELECTORS]', summary: 'Diagram only: remove overlaps and gaps < 20 px with minimal moves, keeping the order (default: every shape). Same as `bpmn layout --tidy`.', examples: ['bpmn tidy order.bpmn'] }, - { name: 'compact', arguments: ' [ids...]', help: 'diagram only: close empty rows and columns and shrink pools, lanes and expanded sub-processes to their content (default: the whole drawing), keeping the order; never adds a layout problem', usage: 'bpmn compact [...]', summary: 'Diagram only: close empty rows and columns and shrink expanded sub-processes, lanes and pools to their content (default: the whole drawing), keeping the order and relative positions. A gap whose closing would add a layout problem stays open (noted).', examples: ['bpmn compact order.bpmn', 'bpmn compact order.bpmn Lane_Backoffice'] }, + { name: 'compact', arguments: ' [ids...]', help: 'diagram only: close empty rows and columns and shrink pools, lanes and expanded sub-processes to their content (default: the whole drawing), keeping the order; never adds a layout problem', usage: 'bpmn compact [...]', summary: 'Diagram only: close empty rows and columns and shrink expanded sub-processes, lanes and pools to their content (default: the whole drawing), keeping the order and relative positions. A gap whose closing would add a layout problem stays open (noted).', examples: ['bpmn compact order.bpmn', 'bpmn compact order.bpmn lane_backoffice'] }, { name: 'validate', arguments: '', help: "structural validation, lint, the engine profile (Camunda 7 / 8), the project's .bpmnlintrc and a layout dry run (nothing is written; file - = stdin)", usage: 'bpmn validate [--json] [--strict] [--platform auto|c7|c8|none] [--lint-config | --no-lint-config]', summary: 'Structural errors (an error a change introduces blocks its write), lint warnings, the engine profile of the file\'s platform (Camunda 7: W_C7_* findings, Camunda 8: W_C8_* findings, each with a severity; W_C7_DEPLOY_* / W_C8_DEPLOY_* = the engine refuses the file) and the project\'s .bpmnlintrc (bpmnlint: its errors fail the check; see BPMNLINT), without changing the file. The platform is detected from modeler:executionPlatform or the vendor namespace; --platform overrides it.', examples: ['bpmn validate order.bpmn --json', 'bpmn validate order.bpmn --platform c7 --strict', 'bpmn validate order.bpmn --no-lint-config'] }, - { name: 'layout', arguments: '', help: 'redraw the diagram (DI) from the semantic model; --tidy keeps the drawing and only removes overlaps', usage: 'bpmn layout [--expand ] [--collapse ] | bpmn layout --tidy', summary: 'Redraw the whole diagram (DI) from the model, optionally changing which sub-processes are expanded (always redraws, a hand layout is replaced; colours, DI ids and group boxes survive; there is no --no-layout here). --tidy keeps the drawing and only removes overlaps (= bpmn tidy).', examples: ['bpmn layout order.bpmn --collapse Activity_Payment', 'bpmn layout order.bpmn --tidy'] }, + { name: 'layout', arguments: '', help: 'redraw the diagram (DI) from the semantic model; --tidy keeps the drawing and only removes overlaps', usage: 'bpmn layout [--expand ] [--collapse ] | bpmn layout --tidy', summary: 'Redraw the whole diagram (DI) from the model, optionally changing which sub-processes are expanded (always redraws, a hand layout is replaced; colours, DI ids and group boxes survive; there is no --no-layout here). --tidy keeps the drawing and only removes overlaps (= bpmn tidy).', examples: ['bpmn layout order.bpmn --collapse subProcess_payment', 'bpmn layout order.bpmn --tidy'] }, { name: 'metrics', arguments: '', help: 'layout quality of the drawing: score, counts and every problem with the element ids (file - = stdin)', usage: 'bpmn metrics [--json]', summary: 'Layout quality of the drawing: the score and every problem (crossings, overlaps, flows through shapes, labels on lines, nodes outside their lane / pool, frames covering foreign shapes, flows running backwards, flows merging before their target (not a join\'s entries sharing a gateway\'s bottom / top vertex), labels outside their pool, labels on the name of a sub-process, message labels far from their line, ...) with the element ids.', examples: ['bpmn metrics order.bpmn --json'] }, - { name: 'render', arguments: ' [ids...]', help: 'draw the diagram as an image to look at (PNG, or SVG; no browser): the whole plane or a crop (ids, --around, --path, --scope), with halos and numbered layout problems; prints the size, the image tokens and the other planes (file - = stdin)', usage: 'bpmn render [...] [-o ] [--format png|svg] [--plane ] [--around [--depth n] [--inner]] [--path [--via ]] [--scope ] [--highlight ] [--problems] [--tiles] [--max-size ] [--scale ] [--json]', summary: 'Draw the diagram as an image to look at it (no browser; close to bpmn-js, not identical): a PNG (resvg with the bundled Arimo font) or SVG of one diagram plane. Without -o it writes bpmn-cli/.png in the temp directory; `-o -` writes the image to stdout (the report to stderr); the format comes from --format or the -o extension. Prints the path, the size, the image tokens (width * height / 784), the scale, the plane and the other planes (collapsed sub-processes: --plane draws one). Crops (one per call): --around draws exactly the nodes of `show --around` with their flows, data and annotations, --path the nodes and flows of a sequence-flow path, --scope a pool, lane or sub-process, ids exactly these elements; flows to what is left out are drawn faded, pools and lanes as context. --highlight puts a halo around elements; --problems numbers the layout problems of `bpmn metrics` (with a crop: those of its elements) in the image at the defect and lists them with their ids. The long edge stays within --max-size (default 2000; --scale zooms); when labels shrink below 0.75 a note says so and names the crops, and --tiles splits such an image at the diagram columns (show --layout c) into readable tiles -1.png, -2.png, ... --tiles writes only tiles with a shape, label or numbered problem (empty space and connections passing through are left out), at most 40 (more: one image and a note). A file without a diagram is drawn in memory (nothing written), and --problems lists what `bpmn metrics` reports for the file. Elements without a shape or with broken coordinates (not a number, beyond ±1,000,000 px) are named, not drawn. -o takes a .png or .svg name (another name needs --format). A rasteriser failure is E_RENDER. Every mutating command takes --render to draw its result (see LOOKING AT THE DRAWING).', examples: ['bpmn render order.bpmn --problems -o order.png', 'bpmn render order.bpmn --around Gateway_Ok --depth 2 --problems -o ok.png', 'bpmn render order.bpmn --path Event_Start Event_Done --highlight Activity_Check -o path.png', 'bpmn render order.bpmn --scope Lane_Finance -o finance.png', 'bpmn render order.bpmn --tiles -o order.png', 'bpmn render order.bpmn --plane Activity_ReviewClaim -o review.png', 'bpmn render order.bpmn --format svg -o -'] }, + { name: 'render', arguments: ' [ids...]', help: 'draw the diagram as an image to look at (PNG, or SVG; no browser): the whole plane or a crop (ids, --around, --path, --scope), with halos and numbered layout problems; prints the size, the image tokens and the other planes (file - = stdin)', usage: 'bpmn render [...] [-o ] [--format png|svg] [--plane ] [--around [--depth n] [--inner]] [--path [--via ]] [--scope ] [--highlight ] [--problems] [--tiles] [--max-size ] [--scale ] [--json]', summary: 'Draw the diagram as an image to look at it (no browser; close to bpmn-js, not identical): a PNG (resvg with the bundled Arimo font) or SVG of one diagram plane. Without -o it writes bpmn-cli/.png in the temp directory; `-o -` writes the image to stdout (the report to stderr); the format comes from --format or the -o extension. Prints the path, the size, the image tokens (width * height / 784), the scale, the plane and the other planes (collapsed sub-processes: --plane draws one). Crops (one per call): --around draws exactly the nodes of `show --around` with their flows, data and annotations, --path the nodes and flows of a sequence-flow path, --scope a pool, lane or sub-process, ids exactly these elements; flows to what is left out are drawn faded, pools and lanes as context. --highlight puts a halo around elements; --problems numbers the layout problems of `bpmn metrics` (with a crop: those of its elements) in the image at the defect and lists them with their ids. The long edge stays within --max-size (default 2000; --scale zooms); when labels shrink below 0.75 a note says so and names the crops, and --tiles splits such an image at the diagram columns (show --layout c) into readable tiles -1.png, -2.png, ... --tiles writes only tiles with a shape, label or numbered problem (empty space and connections passing through are left out), at most 40 (more: one image and a note). A file without a diagram is drawn in memory (nothing written), and --problems lists what `bpmn metrics` reports for the file. Elements without a shape or with broken coordinates (not a number, beyond ±1,000,000 px) are named, not drawn. -o takes a .png or .svg name (another name needs --format). A rasteriser failure is E_RENDER. Every mutating command takes --render to draw its result (see LOOKING AT THE DRAWING).', examples: ['bpmn render order.bpmn --problems -o order.png', 'bpmn render order.bpmn --around gateway_invoiceOk --depth 2 --problems -o ok.png', 'bpmn render order.bpmn --path startEvent_orderReceived endEvent_done --highlight userTask_checkInvoice -o path.png', 'bpmn render order.bpmn --scope lane_finance -o finance.png', 'bpmn render order.bpmn --tiles -o order.png', 'bpmn render order.bpmn --plane subProcess_reviewClaim -o review.png', 'bpmn render order.bpmn --format svg -o -'] }, { name: 'kinds', help: 'element kinds, triggers, settable keys, ops schema and error codes', usage: 'bpmn kinds [--section ] [--json [--pretty]]', summary: 'Kind table, trigger options, set keys, placement grammar, ops schema and error catalogue; --section prints only the named parts (kinds, triggers, setKeys, nestedKeys, zeebeElements, placement, ops, layoutModes, ids, colors, errors, exitCodes; with --json those keys).', examples: ['bpmn kinds', 'bpmn kinds --section ops --json', 'bpmn kinds --section errors'] }, { name: 'guide', arguments: '[topic]', help: 'cheat sheet for AI agents (--short: the core in 5 KB; a topic: one section)', usage: 'bpmn guide [--short | ]', summary: 'This cheat sheet; --short the core in at most 5 KB, one section (see TOPICS).', examples: ['bpmn guide --short', 'bpmn guide format'] }, ]; diff --git a/src/adapters/in/report/KindsReference.ts b/src/adapters/in/report/KindsReference.ts index 363466b8..d5b5125a 100644 --- a/src/adapters/in/report/KindsReference.ts +++ b/src/adapters/in/report/KindsReference.ts @@ -1,6 +1,7 @@ import { LAYOUT_MODES } from '../../../domain/diagram/types.js'; import { Doc } from '../../../domain/document/Doc.js'; import { EditSession } from '../../../domain/document/EditSession.js'; +import { DEFAULT_ID_CONVENTIONS } from '../../../domain/document/ids/IdConventions.js'; import { KINDS, TRIGGERS, type Family, type KindDef, type Trigger } from '../../../domain/document/Kinds.js'; import { SWATCHES } from '../../../domain/diagram/utils/colors.js'; import { OPS_SCHEMA } from './OpsSchemaBuilder.js'; @@ -71,7 +72,7 @@ export class KindsReference { type: kind.type, family: kind.family, aliases: kind.aliases, - prefix: kind.prefix, + prefix: DEFAULT_ID_CONVENTIONS.prefixes[kind.type], ...(kind.triggers ? { triggers: kind.triggers } : {}), ...(kind.props ? { props: kind.props } : {}), description: kind.description, @@ -182,13 +183,13 @@ export class KindsReference { } private kindsSection(): string { - const out: string[] = [heading('KINDS (bpmn add [name] ...; aliases in parentheses; id prefix in the 2nd column)')]; + const out: string[] = [heading('KINDS (bpmn add [name] ...; aliases in parentheses; id prefix without a .bpmnlintrc in the 2nd column)')]; const families = [...new Set(KINDS.map((kind) => kind.family))]; for (const family of families) { out.push(`\n${FAMILY_LABELS[family]}`); const rows: string[][] = []; for (const def of KINDS.filter((kind) => kind.family === family)) { - rows.push([this.kindLabelWithAliases(def), `${def.prefix}_`, def.description]); + rows.push([this.kindLabelWithAliases(def), DEFAULT_ID_CONVENTIONS.prefixes[def.type]!, def.description]); if (def.triggers) rows.push(['', '', `triggers: ${def.triggers.join(', ')}`]); } out.push(table(rows)); diff --git a/src/adapters/in/report/ModelViewText.ts b/src/adapters/in/report/ModelViewText.ts index 81b6efa8..e0230bfe 100644 --- a/src/adapters/in/report/ModelViewText.ts +++ b/src/adapters/in/report/ModelViewText.ts @@ -4,7 +4,7 @@ import { annotationLine, elementFlags, elementLine, flagValue, flowText, INDENT, /** * The model view as text, the default output of `show`: one line per node with its flows inline - * and every id visible, e.g. `userTask Activity_Check "Check" [lane=Lane_Sales] -> Gateway_Ok`. + * and every id visible, e.g. `userTask userTask_check "Check" [lane=lane_sales] -> gateway_ok`. * Deterministic, compact, no coordinates. */ export class ModelViewText { diff --git a/src/adapters/in/report/MutationReportBuilder.ts b/src/adapters/in/report/MutationReportBuilder.ts index 69d7672b..3767f0ef 100644 --- a/src/adapters/in/report/MutationReportBuilder.ts +++ b/src/adapters/in/report/MutationReportBuilder.ts @@ -39,7 +39,6 @@ export class MutationReportBuilder { validation, importWarnings: result.importWarnings, ...(result.view ? { view: result.view } : {}), - ...(result.conventions ? { conventions: result.conventions } : {}), }; } diff --git a/src/adapters/in/report/MutationSummaryBuilder.ts b/src/adapters/in/report/MutationSummaryBuilder.ts index 8009a7c5..d328144e 100644 --- a/src/adapters/in/report/MutationSummaryBuilder.ts +++ b/src/adapters/in/report/MutationSummaryBuilder.ts @@ -37,7 +37,6 @@ export class MutationSummaryBuilder { ...(metrics && !metrics.before && metrics.added.length ? { problems: metrics.added.length } : {}), }, ...(report.layout.format?.length ? { format: report.layout.format } : {}), - ...(report.conventions ? { conventions: report.conventions } : {}), }; } diff --git a/src/adapters/in/report/MutationText.ts b/src/adapters/in/report/MutationText.ts index 1cdacd5f..8b1c6345 100644 --- a/src/adapters/in/report/MutationText.ts +++ b/src/adapters/in/report/MutationText.ts @@ -1,7 +1,7 @@ import type { MutationReportLike } from './command-report.js'; import { WarningLines } from './WarningLines.js'; import { advisoryOf, findingList, validatorTag } from './utils/findings.js'; -import { aliasLine, conventionsLines, fileLines, validatorLines } from './utils/mutation-lines.js'; +import { aliasLine, fileLines, validatorLines } from './utils/mutation-lines.js'; import { hasNoEntries, renderChanges, renderLayout } from './utils/result-lines.js'; import { ModelViewText } from './ModelViewText.js'; @@ -28,7 +28,6 @@ export class MutationText { if (result.warnings.resolved.length) lines.push(`resolved: ${findingList(result.warnings.resolved)}`); if (result.warnings.preexistingCount) lines.push(MutationText.preexistingLine(result.warnings.preexistingCount)); lines.push(...validatorLines(result.validation.validators, 'result')); - lines.push(...conventionsLines(result.conventions)); lines.push(...renderLayout(result.layout)); lines.push(...fileLines(result, this.opts)); if (result.view) lines.push('', new ModelViewText(result.view).text().trimEnd()); diff --git a/src/adapters/in/report/SummaryText.ts b/src/adapters/in/report/SummaryText.ts index 2e81d635..4f173508 100644 --- a/src/adapters/in/report/SummaryText.ts +++ b/src/adapters/in/report/SummaryText.ts @@ -1,7 +1,7 @@ import type { MutationSummary } from './command-report.js'; import { WarningLines } from './WarningLines.js'; import { validatorTag } from './utils/findings.js'; -import { aliasLine, conventionsLines, fileLines, leftOutText } from './utils/mutation-lines.js'; +import { aliasLine, fileLines, leftOutText } from './utils/mutation-lines.js'; import { formatLine } from './utils/result-lines.js'; /** @@ -37,7 +37,6 @@ export class SummaryText { if (layout.added?.length) lines.push(`${head}; added: ${layout.added.map((placed) => `${placed.kind} [${placed.ids.join(', ')}]`).join(', ')}`); else if (layout.problems) lines.push(`${head} (${layout.problems} layout problem${layout.problems === 1 ? '' : 's'}: \`bpmn metrics \` lists them)`); else lines.push(head); - lines.push(...conventionsLines(summary.conventions)); lines.push(...fileLines(summary, opts)); return lines.join('\n'); } diff --git a/src/adapters/in/report/command-report.ts b/src/adapters/in/report/command-report.ts index b5327b27..a18efd83 100644 --- a/src/adapters/in/report/command-report.ts +++ b/src/adapters/in/report/command-report.ts @@ -6,7 +6,6 @@ import type { LayoutProblem } from '../../../domain/diagram/utils/problems.js'; import type { DiagramCommandResult } from '../../../domain/diagram/commands/DiagramCommands.js'; import type { Finding } from '../../../domain/document/Finding.js'; import type { CheckResult } from '../../../application/port/in/ValidateDocumentUseCase.js'; -import type { ConventionsReport } from '../../../domain/batch/types.js'; import type { LayoutStatus } from '../../../domain/diagram/types.js'; import type { PlatformSummary } from '../../../domain/validation/profiles/PlatformProfiles.js'; import type { Change } from '../../../domain/document/ChangeSet.js'; @@ -45,8 +44,6 @@ export interface MutationReport { validation: Omit; importWarnings: string[]; view?: ModelView; - /** new ids that follow the declared id conventions rather than the file's own style */ - conventions?: ConventionsReport; } /** The JSON of `bpmn validate`. */ @@ -82,5 +79,4 @@ export interface MutationSummary { /** without a drawing before, only how many problems the result has */ layout: { status: 'ok' | 'skipped'; mode?: 'full' | 'incremental'; score?: { before?: number; after: number }; added?: LayoutProblem[]; problems?: number }; format?: DiagramCommandResult[]; - conventions?: ConventionsReport; } diff --git a/src/adapters/in/report/guide.ts b/src/adapters/in/report/guide.ts index 2162622c..75d56087 100644 --- a/src/adapters/in/report/guide.ts +++ b/src/adapters/in/report/guide.ts @@ -33,9 +33,9 @@ const USAGE_AND_IO_ERRORS: ErrorDoc[] = [ /** Codes of ids and their lookup. */ const ID_LOOKUP_ERRORS: ErrorDoc[] = [ - { code: 'E_NOT_FOUND', meaning: 'No element with the given id (elements are addressed by id, never by name). The candidates are the meant ids, best first: the new id of a flow an earlier op of the batch renamed, the same id in another case, umlaut spelling or with another / no prefix (Task_CheckInvoice, CheckInvoice -> Activity_CheckInvoice), ids containing it, typos (ChekInvoice), names containing it; within each, the ids the batch created first. In a batch the hint lists the ids it created so far (and an alias written without $). Ids of vendor extension elements (e.g. camunda:formField) are not element ids: the message then names the BPMN element they belong to.', fix: 'Use the candidates listed, or run `bpmn show ` / `bpmn find ` to list ids; in `apply`, name what an op creates with "as": "$name". `remove --if-exists` / `add --if-absent` make the op idempotent.' }, + { code: 'E_NOT_FOUND', meaning: 'No element with the given id (elements are addressed by id, never by name). The candidates are the meant ids, best first: the new id of a flow an earlier op of the batch renamed, the same id in another case, umlaut spelling or with another / no prefix (task_checkInvoice, checkInvoice -> userTask_checkInvoice), ids containing it, typos (ChekInvoice), names containing it; within each, the ids the batch created first. In a batch the hint lists the ids it created so far (and an alias written without $). Ids of vendor extension elements (e.g. camunda:formField) are not element ids: the message then names the BPMN element they belong to.', fix: 'Use the candidates listed, or run `bpmn show ` / `bpmn find ` to list ids; in `apply`, name what an op creates with "as": "$name". `remove --if-exists` / `add --if-absent` make the op idempotent.' }, { code: 'E_WRONG_KIND', meaning: 'The element exists but is not of the expected kind (e.g. a flow where a node is required); also `ext add` on bpmn:definitions or a non-BPMN element (the BPMN 2.0 XSD has no extensionElements there; the engines refuse the file), or a definition. / loop. / condition. prefix the element has no slot for; `compact` with an id that is no pool, lane or expanded sub-process; `render --plane` with an element without a plane of its own, `render --scope` with one that is no pool, lane, sub-process or process.', fix: 'Pass an id of the right kind; `bpmn show ` prints the kind. Process-level extensions go on the process id.' }, - { code: 'E_INVALID_ID', meaning: 'The id is not an XML NCName ([A-Za-z_][A-Za-z0-9_.-]*).', fix: 'Use letters, digits, _ . - and start with a letter or _, e.g. Activity_CheckInvoice.' }, + { code: 'E_INVALID_ID', meaning: 'The id is not an XML NCName ([A-Za-z_][A-Za-z0-9_.-]*).', fix: 'Use letters, digits, _ . - and start with a letter or _, e.g. userTask_checkInvoice.' }, { code: 'E_DUPLICATE_ID', meaning: 'An element with that id already exists (BPMN and diagram elements only: ids inside vendor extension elements, e.g. two camunda:formField with the same id in different forms, are not checked). In `validate`: two elements of the file have one id (bpmn-moddle keeps the first and drops the later one with an import warning; the engines refuse the file).', fix: 'Choose another --id, omit it (ids are generated from the name), or use --if-absent. In a file: give one of the elements another id in the XML.' }, { code: 'E_UNKNOWN_KIND', meaning: 'The kind token is not a known kind, alias or bpmn type.', fix: 'Run `bpmn kinds`; the message suggests close matches.' }, { code: 'E_UNSUPPORTED_KIND', meaning: 'The kind exists in BPMN but is not supported (choreographies).', fix: 'Model it differently with the kinds `bpmn kinds` lists.' }, @@ -79,7 +79,7 @@ const FORMAT_OPERATION_ERRORS: ErrorDoc[] = [ { code: 'E_NO_SHAPE', meaning: 'The element exists but has no shape or connection in the diagram (added with --no-layout, a flow whose ends a --no-layout write changed, a process that is drawn as its pool, an element outside the drawn root).', fix: 'For a pool use the participant id, not the process id. A flow: `bpmn route ` draws its line. Else the next model edit without --no-layout draws it (`bpmn layout ` redraws all).' }, { code: 'E_DIFFERENT_DIAGRAM', meaning: '`place` / `align`: the element and its reference are drawn on different diagrams (the content of a collapsed sub-process has its own).', fix: 'Use a reference on the same diagram, or expand the sub-process first (`bpmn set expanded=true`).' }, { code: 'E_LEAVES_CONTAINER', meaning: '`place` / `align` would move a node out of its lane, pool or expanded sub-process: its centre would leave the frame, its box would start more than 10 px into the header of its lane / pool (before the border of a sub-process), the reference is the node\'s own frame (a node is not placed beside the sub-process it lives in), or the reference lies outside the node\'s sub-process and the node would land outside it (a sub-process grows towards a reference inside it, never out to one outside). `align` leaves out (noted) a member only a selector (--kind / --path / --branch) named.', fix: 'Change the membership first (`bpmn move --lane `; in a batch a move op before the place op; the hint names the lane at the target position), or choose a reference inside the frame.' }, - { code: 'E_NO_ROOM', meaning: '`place` / `align`: there is no room for the request: after the shapes in the way gave way, the requested row / column no longer holds (e.g. a node placed onto its own reference, or into a gap that is too small next to it), or the lane / sub-process of the moved shape would have to grow over a reference outside it, or push the reference\'s own pool / lane / sub-process away, or two shapes would end up on each other (a reference that would be overrun moves along when its row / column still holds), or a shape would be pushed out of a sub-process, lane or pool that held it. The refusal of `place` / `align` carries one space-tool run that makes the room (details.ops: a batch op; details.command, e.g. `bpmn space --after Gateway_Budget --by 2col`, the file filled in on the command line, only when the refused op saw the file\'s own drawing: not after other ops of a batch, whose changes and new ids the file does not have): a dry run inside the op checked that the request fits after it, and of the runs that do, it is the one with the fewest layout problems; without one, the hint names the shapes to move first. `place --side`: the border is taken by other boundary events and docking connections. `space --by -`: closing the empty space there would add a layout problem (the message names it).', fix: 'Run the command of the hint, then place again (in a batch: its op before the place op; after other ops of a batch the hint names only that op); without one, move the shapes the hint names first, or use a reference inside the same lane / sub-process.' }, + { code: 'E_NO_ROOM', meaning: '`place` / `align`: there is no room for the request: after the shapes in the way gave way, the requested row / column no longer holds (e.g. a node placed onto its own reference, or into a gap that is too small next to it), or the lane / sub-process of the moved shape would have to grow over a reference outside it, or push the reference\'s own pool / lane / sub-process away, or two shapes would end up on each other (a reference that would be overrun moves along when its row / column still holds), or a shape would be pushed out of a sub-process, lane or pool that held it. The refusal of `place` / `align` carries one space-tool run that makes the room (details.ops: a batch op; details.command, e.g. `bpmn space --after gateway_budget --by 2col`, the file filled in on the command line, only when the refused op saw the file\'s own drawing: not after other ops of a batch, whose changes and new ids the file does not have): a dry run inside the op checked that the request fits after it, and of the runs that do, it is the one with the fewest layout problems; without one, the hint names the shapes to move first. `place --side`: the border is taken by other boundary events and docking connections. `space --by -`: closing the empty space there would add a layout problem (the message names it).', fix: 'Run the command of the hint, then place again (in a batch: its op before the place op; after other ops of a batch the hint names only that op); without one, move the shapes the hint names first, or use a reference inside the same lane / sub-process.' }, { code: 'E_NO_LABEL', meaning: '`label`: the element has no name, so there is no label to place.', fix: 'Name it first: `bpmn set name=...`.' }, ]; @@ -122,7 +122,7 @@ const WRITE_TIME_ERRORS: ErrorDoc[] = [ /** Warnings of the ops. */ const OP_WARNINGS: ErrorDoc[] = [ - { code: 'W_ID_SUFFIXED', meaning: 'The id generated for a new element (from its name, its kind and place, or the ends of a flow) already existed (for an unnamed gateway or activity also with its kind spelled out, Gateway_ParallelAfterCheck, and with the kind of the unnamed anchor it follows, Task_AfterCheckGateway), so a _2/_3 suffix was appended (2/3 without `_` where element-id-naming of the .bpmnlintrc checks the id in camelCase or PascalCase, and in stemTo / scopedTo flows; after an id that ends in a digit No2/No3, serviceTask_step1No2, so it never reads as another number; a long id loses whole words first, to stay within 64 characters): a name the file already has, or unnamed elements that only an index tells apart.', fix: 'Use the reported id, pass an explicit id (--id, --flow-id, "id", "flowId", "joinId") to choose one yourself, or give the element a distinct name.' }, + { code: 'W_ID_SUFFIXED', meaning: 'The id generated for a new element (from its name, its kind and place, or the ends of a flow) already existed (for an unnamed gateway or activity also with its kind spelled out, gateway_parallelAfterCheck, and with the kind of the unnamed anchor it follows, task_afterCheckGateway), so a suffix was appended (2/3 under camelCase and PascalCase, the default; _2/_3 under snake_case, -2/-3 under kebab-case; after an id that ends in a digit No2/No3, serviceTask_step1No2, so it never reads as another number; a long id loses whole words first, to stay within 64 characters): a name the file already has, or unnamed elements that only an index tells apart.', fix: 'Use the reported id, pass an explicit id (--id, --flow-id, "id", "flowId", "joinId") to choose one yourself, or give the element a distinct name.' }, { code: 'W_ID_PREFIX_STALE', meaning: 'A retype or a trigger change (`retype`, `set trigger=` and the other trigger keys) kept an id whose prefix the project\'s id conventions (element-id-naming of the .bpmnlintrc) give what the element was, or that they did not check for it (a type switched off), and do not accept for what it is now (serviceTask_checkInvoice, now a user task; timerEvent_x, now a message event). Ids are API, so the element keeps it; element-id-naming reports it from now on. Only the change that made it stale reports it (an apply batch: once per element, from its state before the batch to its final state). An element the same batch created (a split\'s join too) is nobody\'s API yet: it takes the new prefix instead, and the flows the batch named after it follow, without the warning.', fix: 'Keep the id while code, tests or links use it. To follow the convention, run the hint\'s `bpmn set id=` (the new prefix and the rest of the id; where the rest did not pass the rule, e.g. a body in another case, the id of what the element is now; free in the file after the batch).' }, { code: 'W_FLOW_ID_STALE', meaning: 'With flow ids kept (--keep-flow-ids or BPMN_KEEP_FLOW_IDS=1; library: keepFlowIds) a flow the file had kept its id although the change gave it other ends (a splice, a bridge, a move, `set source=` / `target=`; also where an end got another id or name earlier in the batch): the id names ends the flow no longer has. Only the change that made it stale reports it, once per flow and against its ends after the whole batch; a flow the same change created follows its ends, one it renamed with `set id=` takes that id, and `bpmn validate` never lists it.', fix: 'Keep the id while code or tests use it. To rename it deliberately, together with them: `bpmn set id=` (the hint names the id its new ends give, or the --flow-id / "flowId" the splice into it was given; a second flow between the same ends gets `_2`, so every hint can be followed).' }, { code: 'W_KIND_MISMATCH', meaning: '`add --if-absent`: the id exists but with another kind; nothing was changed.', fix: 'Use `bpmn retype ` to change its kind, or pick another id.' }, @@ -257,7 +257,7 @@ const PLATFORM_PROFILE_FINDINGS: ErrorDoc[] = [ const PROJECT_BPMNLINT_FINDINGS: ErrorDoc[] = [ { code: 'W_LINT_UNAVAILABLE', meaning: 'Something the project\'s .bpmnlintrc names is left out, the rest of it runs (`bpmn validate` and `bpmn new` list it, and `--strict` exits 5; `bpmn show --conventions` lists it too; a later write finds it before and after the change, only counts it, and its validator line says `N left out` or, when the whole file is left out, `bpmnlint did not run`): a config, rule or moddle extension that is neither bundled with bpmn-cli nor installed inside the repository next to the .bpmnlintrc, a package that fails to load, a project plugin the host does not run (library use without lintProjectCode; outside a git repository; in a repository of another user, by the owners of its directory and .git as git checks them, not git\'s safe.directory, on Windows one outside the home directory; a .git file that names no git directory; the rest of the .bpmnlintrc runs: root over another uid\'s checkout in a CI container gets the repository\'s .bpmnlintrc with the rules bpmn-cli ships), a moddle extension that is no JSON file or that BpmnModdle rejects (a prefix or uri already defined, an extends of an unknown type: bpmnlint fails on the whole file with it, here only that extension is left out), a config that extends itself (its rules apply once). Or bpmnlint did not run at all: a .bpmnlintrc found by the lookup that is not strict JSON, or bpmnlint / @miragon/bpmnlint-plugin-rules could not be loaded (e.g. a bpmnlint older than 11.14 next to the plugin in the project\'s node_modules: npm keeps the plugin with bpmn-cli\'s own bpmnlint then, an install with --legacy-peer-deps or a package manager that ignores peer dependencies does not). The message names what and why.', fix: 'Install the package inside the repository (`npm i -D `), keep the .bpmnlintrc and its plugin in a git repository of your own (or run bpmn-cli as the repository\'s owner), correct the name (the hint names a bundled rule of that name), fix the moddle descriptor, write the file as strict JSON, or update the project\'s bpmnlint to 11.14 or later (or install without --legacy-peer-deps); `--no-lint-config` skips bpmnlint.' }, { code: 'W_LINT_FAILED', meaning: 'A rule of the project\'s .bpmnlintrc threw (its factory on the options, or its check on an element; also an async check that rejects or a factory that returns a promise): that rule\'s findings are left out, the other rules\' are reported. Or bpmnlint could not lint the model at all (a moddleExtensions descriptor BpmnModdle takes but cannot parse the file with, e.g. one that redefines a property bpmn does not have): no rule ran (`bpmn validate` and `bpmn new` list it); a later write does not compare it, and its validator line says `1 left out (W_LINT_FAILED, no rule ran; ...)` (--summary: a `validator bpmnlint:` line).', fix: 'Fix the rule or its options (the message names both), or the moddleExtensions; `--no-lint-config` skips bpmnlint.' }, - { code: 'W_CONFIG_IGNORED', meaning: 'A value of @miragon/rules/element-id-naming in the project\'s .bpmnlintrc that new ids cannot follow is ignored (the message names the .bpmnlintrc, the key and the value): a prefix that is no string, cannot start an XML id (a letter or _ first, then letters, digits, _ . -), has a character outside ASCII (bpmn-cli writes ASCII ids) or is longer than 56 characters (new ids of that type follow the learned id style, and the rule reports them; under eventDefinitionQualifier required also a qualified prefix that long, for the events of its trigger), a prefix declared for a DI type bpmn-cli writes ids for (bpmndi:BPMNShape, BPMNEdge, BPMNPlane, BPMNDiagram; not BPMNLabel, which has none: bpmn-cli names DI after its element, BPMNShape_ or _di like bpmn-js, and the rule reports the new DI ids), an unknown `case` (the rule checks its fallback, PascalCase, and new ids take it) or `eventDefinitionQualifier` (optional). It never blocks: `bpmn validate`, `bpmn new` and `bpmn show --conventions` list it, a later write finds it before and after the change and only counts it (its validator line: `N element-id-naming values ignored`).', fix: 'Fix the value in the .bpmnlintrc (the hint names the allowed ones), or set the prefix to false to switch the type off; `--no-lint-config` skips the .bpmnlintrc.' }, + { code: 'W_CONFIG_IGNORED', meaning: 'A value of @miragon/rules/element-id-naming in the project\'s .bpmnlintrc that new ids cannot follow is ignored (the message names the .bpmnlintrc, the key and the value): a prefix that is no string, cannot start an XML id (a letter or _ first, then letters, digits, _ . -), has a character outside ASCII (bpmn-cli writes ASCII ids) or is longer than 56 characters (new ids of that type take bpmn-cli\'s own prefix, and the rule reports them; under eventDefinitionQualifier required also a qualified prefix that long, for the events of its trigger: they take the plain prefix), a prefix declared for a DI type bpmn-cli writes ids for (bpmndi:BPMNShape, BPMNEdge, BPMNPlane, BPMNDiagram; not BPMNLabel, which has none: bpmn-cli names DI after its element, BPMNShape_ or _di like bpmn-js, and the rule reports the new DI ids), an unknown `case` (the rule checks its fallback, PascalCase, and new ids take it) or `eventDefinitionQualifier` (optional). It never blocks: `bpmn validate`, `bpmn new` and `bpmn show --conventions` list it, a later write finds it before and after the change and only counts it (its validator line: `N element-id-naming values ignored`).', fix: 'Fix the value in the .bpmnlintrc (the hint names the allowed ones), or set the prefix to false to switch the type off; `--no-lint-config` skips the .bpmnlintrc.' }, { code: 'E_LINT_CONFIG', meaning: '`--lint-config ` (library: lintConfig) names a file that cannot be read or is not strict JSON (exit 4). A broken .bpmnlintrc found by the lookup is W_LINT_UNAVAILABLE instead.', fix: 'Pass an existing .bpmnlintrc in strict JSON (no comments, no trailing commas: bpmnlint reads it with JSON.parse), or `--no-lint-config`.' }, ]; @@ -311,25 +311,25 @@ export const PLACEMENT_DOCS: PlacementDoc[] = [ { option: '--after ', key: 'after', rule: 'X is a gateway or has no outgoing flow: append X -> node. X has exactly one outgoing flow: splice, X -> node -> old successor. Otherwise E_HAS_SUCCESSOR (say which flow).' }, { option: '--before ', key: 'before', rule: 'Symmetric: prepend before a join gateway or a node without incoming flow, else splice into its single incoming flow; several incoming flows -> E_HAS_PREDECESSOR.' }, { option: '--after --before ', key: 'after + before', rule: 'Splice into the flow X -> Y (E_NO_FLOW / E_AMBIGUOUS_FLOW when there is none / several).' }, - { option: '--flow ', key: 'flow', rule: 'Splice into sequence flow F: A -> B becomes A -> node -> B. F keeps its name and condition and now ends at the node; it keeps its id too, unless the id named A and B (Flow_AToB is renamed Flow_AToNode; with --keep-flow-ids it stays, W_FLOW_ID_STALE, which suggests a --flow-id given for it).' }, + { option: '--flow ', key: 'flow', rule: 'Splice into sequence flow F: A -> B becomes A -> node -> B. F keeps its name and condition and now ends at the node; it keeps its id too, unless the id named A and B (flow_aToB is renamed flow_aToNode; with --keep-flow-ids it stays, W_FLOW_ID_STALE, which suggests a --flow-id given for it).' }, { option: '--in ', key: 'in', rule: 'Put the node into process / sub-process / participant S without connecting it (connect it afterwards).' }, { option: '--on ', key: 'on', rule: 'Boundary events only: attach to activity A.' }, { option: '--to ', key: 'to', rule: 'Additionally connect node -> T (a branch that re-joins the main path).' }, ]; export const ID_CONVENTIONS = { - rule: 'Elements are addressed by id only (names may repeat). New ids speak (never a hash or a running number) and follow the id style of the file (prefix, case, flow form), and the id conventions the project declares where it declares them (see declared); explicit ids (--id, "id") are taken as given. A rule needs two ids that follow it (one id of a kind in a file whose ids are mostly prefixed).', - declared: 'where element-id-naming of the project\'s .bpmnlintrc declares a prefix for a BPMN type, new ids of it take that prefix and the declared case (serviceTask_checkInvoice, flow_checkInvoiceToBookInvoice; required qualifier: timerStartEvent_monthly); under a declared camelCase / PascalCase a taken id gets a glued 2 (serviceTask_checkInvoice2; after a digit No2: serviceTask_step1No2) and a split\'s join is Join (gateway_approvedJoin); the rest follows the file, learned without the ids that already follow the rule; process and definitions ids stay bpmn-cli\'s unless named exactly (a new pool\'s process: the prefix the file\'s process ids share, see prefixes); existing ids never change (W_ID_PREFIX_STALE); `bpmn show --conventions` lists them, `bpmn guide conventions` has the rules', - default: { element: '_ (prefix: `prefix` of the kind)', unnamed: '_ (Gateway_AfterCheckInvoice, Event_TimerOnReview, Event_EndAfterTimerOnReview, Event_ErrorStartInHandleErrors, Gateway_Parallel)', flow: 'Flow_To (Flow_CheckInvoiceToBookInvoice)', di: '_di in files with DI of that form; BPMNShape_ / BPMNEdge_ in a new drawing (a long id cut at whole words: every DI id within 64 characters)' }, - prefixes: 'per kind (and trigger) as the file has them; else the family prefix the file shares (Task_); else the type name when the file names prefixes after types (serviceTask_); else the bpmn-cli prefix in the case of the file. No prefix counts too: where the file\'s ids of a kind or family have none (checkOrder), or its flow nodes mostly have none, a named element gets a bare camelCase (PascalCase) id; an unnamed one keeps a prefix. A new pool\'s process takes the prefix two of the file\'s process ids share, in their case (process_customer, process_shop: process_bankAccount; participant ids never count), also under declared conventions', - bodies: { pascal: 'CheckInvoice', camel: 'checkInvoice', snake: 'check_invoice', pascalSnake: 'Check_Invoice' }, - bodiesNote: 'the case of the named flow nodes\' ids; Camunda Modeler hashes (Activity_0k3x9qa) and numbers (Task_12) show none: pascal with the file\'s prefixes (Activity_CheckInvoice, Task_CheckInvoice)', - unnamed: 'a word for the kind (events: Start, End, MessageStart, ErrorEnd, Timer, MessageThrow; other kinds only without context: Gateway_Parallel) + the context: After , Before , On , In ; after / before an unnamed anchor placed the same way, the nearest named anchor, once (Event_EndAfterCheckInvoice after Gateway_AfterCheckInvoice, never EndAfterAfter...), with the anchor\'s kind where the id would repeat the anchor\'s (Task_AfterCheckInvoiceGateway); a name without a letter (123) names nothing; joins of a split: _join in the file\'s case (camelCase ids and a declared camelCase / PascalCase: Join; a split id without the declared gateway prefix: that prefix + the gateway, gateway_checkJoin)', - flowForms: { named: 'Flow_To in the file\'s case (Flow_CheckInvoiceToBookInvoice; also for files with hashed or numbered flows, with their prefix: SequenceFlow_ArchiveToDone, SF_ThirdToFinish, flowCheckOrderToShip)', stemTo: 'flow_checkStockToShipGoods', idPair: 'Flow__', stemSnake: 'Flow_check_stock_to_ship_goods', idSnake: 'Flow__to_', scopedTo: 'Flow__To (Flow_KotO_ValidateToReserve: the first word with a letter of each end\'s id; Start / End for start and end events)' }, - ends: 'a flow end or an anchor is named by the speaking part of its id (CheckInvoice; an unnamed element by its own kind and place, Gateway_AfterCheckInvoice -> AfterCheckInvoice, the join of the split after it -> CheckInvoiceJoin: Flow_CheckInvoiceToAfterCheckInvoice, so the flows at two unnamed gateways differ), by its name when the id says too little (a hash, a number, one or two letters), else by a word for its kind (Gateway, End, Timer); an id with a number and a word speaks (Gateway_AfterCheck2024 -> AfterCheck2024); hashes, numbers, machine ids and StartEvent_1 are never copied', - length: 'at most 64 characters, a collision suffix included; a name gives at most 40 characters of words (a single longer word is cut at 40), and an id within 64 is not cut again; a longer one loses whole words the same way every time: the two ends of a flow share the room (each gives up its last words, never its first word, its last word with a letter before its Join or number, a word between two numbers, or the numbers before a number it ends with (x_2_2 stays whole), and a number left at the end of the id goes too where it reads like a collision suffix, and so does a filler word a cut would end on; of the cuts that fit, the one with the most words, then the most characters; a flow named by its ends\' ids keeps each id\'s prefix unless the prefixes leave fewer than 16 characters, then the whole ids share the room), a join\'s Join, a number and a collision suffix are kept; a word is cut inside only when no whole word is left to give up (each end of a flow keeps its first word, or its last where the two begin alike), and then in the longer end, keeping its Join or number (in both ends where the longer would keep fewer than 8 characters); a cut keeps a letter (a room that ends inside a leading number starts at the first word with a letter); a file prefix of more than 52 characters (fewer than 8 left for the body beside a suffix up to _99) stays where the body\'s words up to its first one with a letter (no filler word) fit beside it whole (an unnamed element\'s up to its anchor\'s first word, afterCheck), else the bpmn-cli prefix (activity_auftrag, also for one id of the file), a flow prefix or scope that leaves a flow\'s ends fewer than 16 stays where the ends fit beside it at whole words, else a named flow with Flow_ (one file can hold both; also where a suffix leaves the ends no room: Flow_Validate_To_Step1_2); a cut (a name\'s 40 characters, a body, a flow\'s end) never ends on a filler word, und, oder, an, die, the, of, ... (LieferantenbestellungVersenden, not ...VersendenUnd), the first word stays, and so does one between two numbers (StartAfter2Und2)', - collisions: 'the same edit gives the same ids; an unnamed gateway or activity whose id is taken, or whose body another id has (the unnamed gateway it follows), first spells out its kind where the prefix does not say it (Gateway_ParallelAfterCheckInvoice, Activity_ServiceTaskAfterCheckInvoice), then names the kind of the unnamed anchor it follows (Task_AfterCheckInvoiceGateway after ExclusiveGateway_AfterCheckInvoice, Task_AfterCheckInvoiceGatewayTask after that); a flow whose two ends begin with the same word keeps each end\'s last word when cut; a taken id gets _2, _3 (stemTo / scopedTo flows and a declared camelCase / PascalCase: 2, 3, after an id that ends in a digit No2, No3: serviceTask_step1No2, never serviceTask_step12, the id of Step 12; an id the suffix would take past 64 characters loses whole words first) and W_ID_SUFFIXED (a repeated name, or unnamed elements only an index tells apart)', - renames: 'a flow whose id names its ends (the id the file\'s style gives a flow between them, or the one any other flow prefix or form the file\'s flows use gives it, also the named Flow_To id in the file\'s case, which earlier versions gave its flows, whoever wrote it; an end also by its name, and in a batch by the id and name it had before a `set id=` / `name=` earlier in the batch) is renamed after its new ends when a splice, a bridge, a move or `set target=` changes them (its derived DI edge id follows; a flow without id gets one from its new ends, or the --flow-id of the add that splices into it, `id added`); with --keep-flow-ids (BPMN_KEEP_FLOW_IDS=1; library: keepFlowIds) a flow the file had keeps its id and W_FLOW_ID_STALE names it (a flow the same change created still follows its ends); every other id stays', + rule: 'Elements are addressed by id only (names may repeat). New ids speak (never a hash or a running number) and follow the id conventions element-id-naming of the project\'s .bpmnlintrc declares, else the rule\'s defaults (see default), whatever ids the file already has; explicit ids (--id, "id") are taken as given; existing ids are never renamed for the conventions.', + declared: 'where element-id-naming of the project\'s .bpmnlintrc is on, new ids take its prefix per BPMN type and its case (serviceTask_checkInvoice, flow_checkInvoiceToBookInvoice; required qualifier: timerStartEvent_monthly); a type it does not check, or switches off, takes bpmn-cli\'s prefix (see unchecked) with the first letter of the declared prefixes (Message_ next to Activity_) and the declared case; existing ids never change (W_ID_PREFIX_STALE); `bpmn show --conventions` lists them, `bpmn guide conventions` has the rules', + default: { element: ' with element-id-naming\'s default prefix of the type in camelCase (userTask_checkInvoice, gateway_invoiceOk, startEvent_orderReceived, participant_customer, lane_sales, dataObject_invoice)', unnamed: ' (gateway_afterCheckInvoice, event_timerOnReview, endEvent_afterTimerOnReview)', flow: 'flow_To (flow_checkInvoiceToBookInvoice; message flows messageFlow_)', di: '_di in files with DI of that form; BPMNShape_ / BPMNEdge_ in a new drawing (a long id cut at whole words: every DI id within 64 characters)' }, + unchecked: 'types element-id-naming does not check take bpmn-cli\'s prefix: the type\'s local name in lower camelCase (message_, signal_, error_, escalation_, laneSet_, category_, categoryValue_, property_, definitions_, timerEventDefinition_); a backing data object dataObjectItem_, a backing data store dataStoreItem_ (the references are dataObject_ / dataStore_). A process takes no prefix: a named one is its name in the case (orderToCash, the deployment key a call activity names), an unnamed one Process_1 (the next free number); a new pool\'s process takes the pool\'s name; a declared bpmn:Process prefix wins (process_orderToCash)', + bodies: { camel: 'checkInvoice', pascal: 'CheckInvoice', snake: 'check_invoice', kebab: 'check-invoice' }, + bodiesNote: 'the declared case; `any` and no .bpmnlintrc: camelCase', + unnamed: 'a word for the kind (events: Start, End, MessageStart, ErrorEnd, Timer, MessageThrow; other kinds only without context: gateway_parallel) + the context, minus what the prefix says: After , Before , On , In ; after / before an unnamed anchor placed the same way, the nearest named anchor, once (endEvent_afterCheckInvoice after gateway_afterCheckInvoice, never AfterAfter...), with the anchor\'s kind where the id would repeat the anchor\'s (task_afterCheckInvoiceGateway); a name without a letter (123) names nothing; joins of a split: Join (snake_case _join, kebab-case -join; a split id without the gateway prefix or case: that prefix + the gateway, gateway_checkJoin)', + flow: 'To in the case (flow_checkInvoiceToBookInvoice, flow_check_invoice_to_book_invoice, flow_check-invoice-to-book-invoice); an end that begins with a number begins with From where the case wants a letter first', + ends: 'a flow end or an anchor is named by its name, else by the speaking part of its id (checkInvoice; an unnamed element by its own kind and place, gateway_afterCheckInvoice -> afterCheckInvoice), else by a word for its kind (Gateway, End, Timer); an id with a number and a word speaks (gateway_afterCheck2024 -> afterCheck2024); hashes, numbers, machine ids and StartEvent_1 are never copied', + length: 'at most 64 characters, a collision suffix included; a name gives at most 40 characters of words (a single longer word is cut at 40), and an id within 64 is not cut again; a longer one loses whole words the same way every time: the two ends of a flow share the room (each gives up its last words, never its first word, its last word with a letter before its Join or number, a word between two numbers, or the numbers before a number it ends with (x_2_2 stays whole), and a number left at the end of the id goes too where it reads like a collision suffix, and so does a filler word a cut would end on; of the cuts that fit, the one with the most words, then the most characters), a join\'s Join, a number and a collision suffix are kept; a word is cut inside only when no whole word is left to give up (each end of a flow keeps its first word, or its last where the two begin alike), and then in the longer end, keeping its Join or number (in both ends where the longer would keep fewer than 8 characters); a cut keeps a letter (a room that ends inside a leading number starts at the first word with a letter); a cut (a name\'s 40 characters, a body, a flow\'s end) never ends on a filler word, und, oder, an, die, the, of, ... (lieferantenbestellungVersenden, not ...VersendenUnd), the first word stays, and so does one between two numbers (startAfter2Und2)', + collisions: 'the same edit gives the same ids; an unnamed gateway or activity whose id is taken, or whose body another id has (the unnamed gateway it follows), first spells out its kind where the prefix does not say it (gateway_parallelAfterCheckInvoice), then names the kind of the unnamed anchor it follows (task_afterCheckInvoiceGateway after gateway_afterCheckInvoice); a flow whose two ends begin with the same word keeps each end\'s last word when cut; a taken id gets 2, 3 under camelCase / PascalCase (after an id that ends in a digit No2, No3: serviceTask_step1No2, never serviceTask_step12, the id of Step 12), _2 under snake_case, -2 under kebab-case (an id the suffix would take past 64 characters loses whole words first) and W_ID_SUFFIXED (a repeated name, or unnamed elements only an index tells apart)', + renames: 'a flow whose id names its ends in the current form (the id the conventions give a flow between them; an end also by its name, and in a batch by the id and name it had before a `set id=` / `name=` earlier in the batch) is renamed after its new ends when a splice, a bridge, a move or `set target=` changes them (its derived DI edge id follows; a flow without id gets one from its new ends, or the --flow-id of the add that splices into it, `id added`); a flow id in another form (Flow_AToB of bpmn-cli before 0.9, SequenceFlow_1, Flow_0k3x9qa) keeps it; with --keep-flow-ids (BPMN_KEEP_FLOW_IDS=1; library: keepFlowIds) a flow the file had keeps its id and W_FLOW_ID_STALE names it (a flow the same change created still follows its ends); every other id stays', transliteration: 'ä -> ae, ö -> oe, ü -> ue, ß -> ss; ø -> oe, å -> aa, æ -> ae, œ -> oe, ł -> l, þ -> th; other accents dropped (é -> e)', di: 'a full redraw keeps the ids of existing DI elements, planes and diagrams; new DI follows the DI ids of the file, cut like element ids to stay within 64 characters (a taken one gets _2); a DI shape or edge id derived from an element id (_di, BPMNShape_, Edge_) follows `set id=` as it follows a flow renamed after its ends; other DI ids, and plane and diagram ids, stay', }; @@ -337,14 +337,14 @@ export const ID_CONVENTIONS = { export const PLACEMENT_NOTES: string[] = [ 'Exactly one placement per add/move (after + before counts as one). Without any, the node is appended to the only process, unconnected.', 'Flow options --flow-name / --flow-id / --condition / --language / --default describe the flow INTO the new node (from the anchor).', - 'move: several ids form a chain (the first takes the placement, each next one goes after the one before); the node the chain starts after (--after, the source of --flow) must lie outside it, else E_INVALID_PLACEMENT and nothing changes (a --before anchor in the chain is fine). A move that leaves its nodes where they are (move B --after A with A -> B) changes nothing and notes `Activity_B is already after Activity_A`; in a batch its flow keys still go to the flow into the first node, and "flowId" renames that flow. With --after alone (an append) or --before alone (a prepend) a node always moves.', + 'move: several ids form a chain (the first takes the placement, each next one goes after the one before); the node the chain starts after (--after, the source of --flow) must lie outside it, else E_INVALID_PLACEMENT and nothing changes (a --before anchor in the chain is fine). A move that leaves its nodes where they are (move B --after A with A -> B) changes nothing and notes `task_b is already after task_a`; in a batch its flow keys still go to the flow into the first node, and "flowId" renames that flow. With --after alone (an append) or --before alone (a prepend) a node always moves.', 'Branch order in the diagram: the default flow (else the first outgoing flow that leads to an end event, else the first one) continues straight; the other branches go alternately below and above it in declaration order (all below when a default flow exists). `bpmn order` changes the declaration order, `set default=true` picks the continuation.', 'Data objects, data stores, text annotations, lanes and pools take --in only; wire them with `bpmn connect`.', - 'A group takes no placement: --members names what it is drawn around (`bpmn add order.bpmn group "Payment" --members Activity_Pay,Event_Paid`).', + 'A group takes no placement: --members names what it is drawn around (`bpmn add order.bpmn group "Payment" --members serviceTask_pay,endEvent_paid`).', ]; export const NESTED_SELECTORS: Record = { - 'definition[].': 'one of several event definitions of an event, by position (0-based), e.g. \'definition[1].id=Def_Timer\'; plain definition. on such an event is E_AMBIGUOUS_NESTED. `show` and `ext list` name each definition this way; `ext` takes the same prefix (\'definition[0].camunda:field\').', + 'definition[].': 'one of several event definitions of an event, by position (0-based), e.g. \'definition[1].id=timerEventDefinition_reminder\'; plain definition. on such an event is E_AMBIGUOUS_NESTED. `show` and `ext list` name each definition this way; `ext` takes the same prefix (\'definition[0].camunda:field\').', 'definition[].': 'the same by trigger, e.g. \'definition[message].camunda:type=external\' (when the event has one definition of that trigger). Quote the brackets in a shell. `set trigger=` keeps only that definition.', }; diff --git a/src/adapters/in/report/utils/conventions-text.ts b/src/adapters/in/report/utils/conventions-text.ts index 3a6c130c..e16c6524 100644 --- a/src/adapters/in/report/utils/conventions-text.ts +++ b/src/adapters/in/report/utils/conventions-text.ts @@ -3,9 +3,8 @@ import { UNQUALIFIED_NOTE, type ConventionsView, type Undeclared } from '../../. const SOURCE_WORDS: Record = { lint: 'element-id-naming', - file: "the file's ids", - 'lint shape': 'shaped like the declared prefixes (the file shows none)', - default: "bpmn-cli's default", + default: "element-id-naming's defaults (nothing declared)", + 'bpmn-cli': "bpmn-cli's own prefix for a type element-id-naming does not check", }; const UNDECLARED_WORDS: Record string> = { @@ -13,15 +12,14 @@ const UNDECLARED_WORDS: Record string 'no config': () => 'no .bpmnlintrc applies here', 'not loaded': (where) => `the ${where ?? '.bpmnlintrc'} could not be loaded (W_LINT_UNAVAILABLE below)`, off: (where) => `element-id-naming is off or not configured in the ${where ?? '.bpmnlintrc'}`, - 'no prefixes': (where) => `element-id-naming (${where ?? 'the host'}) declares no prefix new ids can follow`, }; export function renderConventions(view: ConventionsView): string { const lines: string[] = []; lines.push(`conventions of ${view.file ?? 'the model'}${view.exists ? '' : ' (no file yet: a new model)'}`); if (view.rule) lines.push(`element-id-naming (${view.source ?? 'the host'}): ${view.rule.severity}, case ${view.rule.case}, eventDefinitionQualifier ${view.rule.qualifier}`); - else if (view.undeclared) lines.push(`no id conventions: ${UNDECLARED_WORDS[view.undeclared](view.source)}; new ids follow the file's id style`); - else lines.push("no id conventions declared: new ids follow the file's id style"); + else if (view.undeclared) lines.push(`no id conventions: ${UNDECLARED_WORDS[view.undeclared](view.source)}; new ids follow element-id-naming's defaults (case camelCase)`); + else lines.push("no id conventions declared: new ids follow element-id-naming's defaults (case camelCase)"); const width = Math.max(...view.rows.map((row) => row.pattern.length)); const sourceWidth = Math.max(...view.rows.map((row) => row.source.length)); for (const row of view.rows) lines.push(` ${row.pattern.padEnd(width)} ${row.source.padEnd(sourceWidth)} ${row.kinds.join(' ')}${row.note ? ` (${row.note})` : ''}`); diff --git a/src/adapters/in/report/utils/findings.ts b/src/adapters/in/report/utils/findings.ts index 35c74657..46548537 100644 --- a/src/adapters/in/report/utils/findings.ts +++ b/src/adapters/in/report/utils/findings.ts @@ -5,7 +5,7 @@ import type { ValidatorReport } from '../../../../domain/validation/validators.j /** This many warnings of one code (and validator) or more are printed as one line. */ export const FLOOD_MIN = 3; -export const FLOOD_IDS = 30; +const FLOOD_IDS = 30; export function validatorTag(warning: Finding): string { return warning.validator ? `[${warning.validator}] ` : ''; @@ -24,7 +24,7 @@ export function floodKey(warning: Finding): string { return `${validatorTag(warning)}${warning.code}`; } -/** `W_DEAD_END Activity_A, W_UNREACHABLE x5 [A, B, C, D, E]` */ +/** `W_DEAD_END task_a, W_UNREACHABLE x5 [A, B, C, D, E]` */ export function findingList(findings: readonly Finding[]): string { const groups = new Map(); for (const finding of findings) groups.set(floodKey(finding), [...(groups.get(floodKey(finding)) ?? []), finding]); diff --git a/src/adapters/in/report/utils/mutation-lines.ts b/src/adapters/in/report/utils/mutation-lines.ts index a25291e4..94fdd328 100644 --- a/src/adapters/in/report/utils/mutation-lines.ts +++ b/src/adapters/in/report/utils/mutation-lines.ts @@ -1,6 +1,4 @@ -import type { ConventionsReport } from '../../../../domain/batch/types.js'; import type { ValidatorReport } from '../../../../domain/validation/validators.js'; -import { FLOOD_IDS } from './findings.js'; function leftOutCount(report: ValidatorReport): number { return (report.leftOut ?? []).reduce((sum, group) => sum + group.count, 0); @@ -34,16 +32,7 @@ export function validatorLines(reports: readonly ValidatorReport[] | undefined, }); } -/** - * Only for new ids under a declared prefix that differ from what the file's own style gives. - */ -export function conventionsLines(conventions: ConventionsReport | undefined): string[] { - if (!conventions?.ids.length) return []; - const ids = conventions.ids.length > FLOOD_IDS ? `${conventions.ids.slice(0, FLOOD_IDS).join(', ')}, +${conventions.ids.length - FLOOD_IDS} more` : conventions.ids.join(', '); - return [`conventions: ${ids} follow${conventions.ids.length === 1 ? 's' : ''} element-id-naming (${conventions.source ?? 'the host'}), not the file's own id style (\`bpmn show --conventions\`)`]; -} - -/** `aliases: $check = Activity_CheckInvoice, $ok = Gateway_InvoiceOk` */ +/** `aliases: $check = userTask_checkInvoice, $ok = gateway_invoiceOk` */ export function aliasLine(aliases: Record): string { return `aliases: ${Object.entries(aliases).map(([alias, id]) => `${alias} = ${id}`).join(', ')}`; } diff --git a/src/adapters/in/report/utils/view-lines.ts b/src/adapters/in/report/utils/view-lines.ts index 1cd05a93..a28b307e 100644 --- a/src/adapters/in/report/utils/view-lines.ts +++ b/src/adapters/in/report/utils/view-lines.ts @@ -137,7 +137,7 @@ export function renderExtensionList(items: ReadonlyArray<{ index: number; slot?: return items.flatMap((item) => extensionLines(item, '', `${item.slot ? `${item.slot}.` : ''}${item.index}: `)).join('\n'); } -/** `out Flow_9 "Order" -> Activity_Receive "Receive" in Participant_Bank "Bank" [message Order]` */ +/** `out messageFlow_sendOrderToReceive "Order" -> receiveTask_receive "Receive" in participant_bank "Bank" [message Order]` */ export function detailMessageFlowText(mf: DetailMessageFlow): string { const pool = mf.pool ? ` in ${named(mf.pool, mf.poolName)}` : ''; return `${mf.direction} ${mf.id}${mf.name ? ` ${quote(mf.name)}` : ''} ${mf.direction === 'out' ? '->' : '<-'} ${named(mf.partner, mf.partnerName)}${pool}${mf.message ? ` [message ${mf.message}]` : ''}${mf.at ? ` (at pool ${mf.at})` : ''}`; diff --git a/src/adapters/out/bpmnlint/IdConventionsReader.ts b/src/adapters/out/bpmnlint/IdConventionsReader.ts index b0a535f6..2214263c 100644 --- a/src/adapters/out/bpmnlint/IdConventionsReader.ts +++ b/src/adapters/out/bpmnlint/IdConventionsReader.ts @@ -8,28 +8,33 @@ import type { ValidatorFinding } from '../../../domain/document/Finding.js'; export const ID_NAMING_RULE = '@miragon/rules/element-id-naming'; -/** The helpers of the plugin's `naming` namespace that element-id-naming runs. */ +/** The resolution element-id-naming runs, as the plugin exports it from 0.19 on. */ +interface ElementIdConvention { + /** null: the type is not checked (no prefix, or switched off) */ + prefixOf(node: unknown): string | null; + /** the plain prefix and the qualified ones the qualifier mode allows */ + prefixesFor(node: unknown): string[]; + readonly case: { label: string; pattern: RegExp }; + readonly qualifier: QualifierMode; +} + +/** What bpmn-cli uses of the plugin's `naming` namespace. */ export interface IdNamingHelpers { - DEFAULT_PREFIXES: Record; - resolvePrefix(element: unknown, prefixes: Record): { type: string; prefix: string } | null; - acceptedPrefixes(element: unknown, prefix: string, mode: QualifierMode): string[]; - resolveCase(caseName: string): { pattern: RegExp; label: string }; - resolveQualifierMode(mode: string | undefined): QualifierMode; + ElementIdConvention: new (config?: unknown) => ElementIdConvention; } -const CASES = ['camelCase', 'PascalCase', 'snake_case', 'any']; +const CASES = ['camelCase', 'PascalCase', 'snake_case', 'kebab-case', 'any']; /** A BPMNLabel is written without an id. */ const DI_WITH_IDS = ['bpmndi:BPMNDiagram', 'bpmndi:BPMNPlane', 'bpmndi:BPMNShape', 'bpmndi:BPMNEdge']; const QUALIFIERS = ['off', 'optional', 'required']; type Moddle = ReturnType; -type Prefixes = Record; /** - * Reads element-id-naming's options from a .bpmnlintrc exactly as the rule does, with its own - * helpers injected (the core never imports the plugin). What new ids cannot follow is left out, - * each with a W_CONFIG_IGNORED note. + * Reads element-id-naming's options from a .bpmnlintrc through the rule's own resolution, + * injected (the core never imports the plugin). What new ids cannot follow is left out, each + * with a W_CONFIG_IGNORED note. */ export class IdConventionsReader { private readonly where: string; @@ -43,24 +48,20 @@ export class IdConventionsReader { this.where = source ?? '.bpmnlintrc'; } - /** - * `conventions` is undefined for no setting, severity off or every prefix switched off. - * Options are read as element-id-naming's factory reads them. - */ + /** `conventions` is undefined for no setting or severity off. */ read(setting: BpmnlintRuleSetting | undefined): { conventions?: IdConventions; notes: ValidatorFinding[] } { const notes = this.notes; if (!setting || setting.severity === 'off') return { notes }; - const options = (setting.config ?? {}) as { prefixes?: Prefixes; case?: string; eventDefinitionQualifier?: string }; - const { prefixes: overrides = {}, case: caseName = 'camelCase' } = options; - const prefixes = { ...this.naming.DEFAULT_PREFIXES, ...overrides }; - const label = this.caseOf(caseName); - const qualifier = this.qualifierOf(options.eventDefinitionQualifier); + const options = (setting.config ?? {}) as { prefixes?: Record; case?: string; eventDefinitionQualifier?: string }; + const convention = new this.naming.ElementIdConvention(setting.config); + const label = this.caseOf(options.case ?? 'camelCase', convention.case.label); + const qualifier = this.qualifierOf(options.eventDefinitionQualifier, convention.qualifier); const moddle = createModdle(); const ignored: string[] = []; - const table = this.typePrefixes(moddle, prefixes, ignored); - if (qualifier !== 'off') this.qualifiedPrefixes(moddle, table, qualifier, ignored); - this.noteDiPrefixes(moddle, prefixes); - if (!Object.keys(table).length) return { notes }; + this.noteUnusablePrefixes(options.prefixes ?? {}); + const table = this.typePrefixes(moddle, convention, ignored); + if (qualifier !== 'off') this.qualifiedPrefixes(moddle, convention, table, qualifier, ignored); + this.noteDiPrefixes(moddle, convention); const conventions: IdConventions = { prefixes: table, qualifier, @@ -81,68 +82,60 @@ export class IdConventionsReader { return near ? ` Did you mean ${near}?` : ''; } - private caseOf(caseName: string): string { - /** - * An unknown case resolves to the rule's fallback, PascalCase. Plugins before 0.12.1 answer - * `toString` or `__proto__` with the Object.prototype property, which has no label. - */ - const resolved = (this.naming.resolveCase(caseName) as { label?: unknown } | undefined)?.label; - const label = typeof resolved === 'string' && CASES.includes(resolved) ? resolved : 'PascalCase'; - const caseHint = `Write one of ${CASES.join(', ')} in the .bpmnlintrc.${this.didYouMean(caseName, CASES)}`; - if (label !== resolved) this.note('case', caseName, 'is unknown, and the rule of a @miragon/bpmnlint-plugin-rules before 0.12.1 cannot check it: new ids take PascalCase, the fallback for an unknown case', caseHint); - else if (label !== caseName) this.note('case', caseName, `is unknown: the rule checks ${label}, and new ids take it`, caseHint); + /** An unknown case resolves to the rule's fallback, PascalCase. */ + private caseOf(configured: string, label: string): string { + if (label !== configured) this.note('case', configured, `is unknown: the rule checks ${label}, and new ids take it`, `Write one of ${CASES.join(', ')} in the .bpmnlintrc.${this.didYouMean(configured, CASES)}`); return label; } - private qualifierOf(mode: string | undefined): QualifierMode { - const qualifier = this.naming.resolveQualifierMode(mode); - if (mode !== undefined && mode !== qualifier) { - this.note('eventDefinitionQualifier', mode, `is unknown: the rule and new ids take ${qualifier}`, `Write one of ${QUALIFIERS.join(', ')} in the .bpmnlintrc.${this.didYouMean(mode, QUALIFIERS)}`); + private qualifierOf(configured: string | undefined, qualifier: QualifierMode): QualifierMode { + if (configured !== undefined && configured !== qualifier) { + this.note('eventDefinitionQualifier', configured, `is unknown: the rule and new ids take ${qualifier}`, `Write one of ${QUALIFIERS.join(', ')} in the .bpmnlintrc.${this.didYouMean(configured, QUALIFIERS)}`); } return qualifier; } - private usable(hit: { type: string; prefix: unknown }): boolean { - if (isUsablePrefix(hit.prefix)) return true; - if (!this.noted.has(hit.type)) { - this.noted.add(hit.type); + /** Noted once per configured key: a supertype's prefix (bpmn:Gateway) covers several types. */ + private noteUnusablePrefixes(prefixes: Record): void { + for (const [type, prefix] of Object.entries(prefixes)) { + if (!prefix || isUsablePrefix(prefix)) continue; const problem = - typeof hit.prefix === 'string' && /[^\x00-\x7f]/.test(hit.prefix) + typeof prefix === 'string' && /[^\x00-\x7f]/.test(prefix) ? 'has a character outside ASCII, which bpmn-cli does not write in ids' : 'cannot start an id (a letter or _ first, then letters, digits, _ . -, at most 56 characters)'; - this.note(`prefix of ${hit.type}`, hit.prefix, `${problem}: new ids of the type follow the learned id style, and the rule reports them`, `Fix the prefix in the .bpmnlintrc, or set it to false to switch ${hit.type} off.`); + this.note(`prefix of ${type}`, prefix, `${problem}: new ids of the type take bpmn-cli's own prefix, and the rule reports them`, `Fix the prefix in the .bpmnlintrc, or set it to false to switch ${type} off.`); } - return false; } - private typePrefixes(moddle: Moddle, prefixes: Prefixes, ignored: string[]): Record { + private typePrefixes(moddle: Moddle, convention: ElementIdConvention, ignored: string[]): Record { const table: Record = {}; const registry = (moddle as unknown as { registry: { packageMap: Record }> } }).registry; for (const moddleType of registry.packageMap['bpmn']!.types) { if (moddleType.isAbstract) continue; const type = `bpmn:${moddleType.name}`; - const hit = this.naming.resolvePrefix(moddle.create(type, { id: 'x' }), prefixes); - if (hit && this.usable(hit)) table[type] = hit.prefix; - else if (hit) ignored.push(type); + const prefix = convention.prefixOf(moddle.create(type, { id: 'x' })); + if (prefix === null) continue; + if (isUsablePrefix(prefix)) table[type] = prefix; + else ignored.push(type); } return table; } - /** A required qualified prefix too long for new ids is ignored (noted once): those ids follow the learned style, which the rule reports. */ - private qualifiedPrefixes(moddle: Moddle, table: Record, qualifier: QualifierMode, ignored: string[]): void { + /** A required qualified prefix too long for new ids is ignored (noted once): those ids take the plain prefix, which the rule reports. */ + private qualifiedPrefixes(moddle: Moddle, convention: ElementIdConvention, table: Record, qualifier: QualifierMode, ignored: string[]): void { for (const def of KINDS) { const plain = table[def.type]; if (plain === undefined || !def.triggers) continue; for (const trigger of def.triggers) { if (trigger === 'none') continue; const event = moddle.create(def.type, { id: 'x', eventDefinitions: [moddle.create(TRIGGERS[trigger].definition)] }); - const qualified = this.naming.acceptedPrefixes(event, plain, qualifier).find((candidate) => candidate !== plain); + const qualified = convention.prefixesFor(event).find((candidate) => candidate !== plain); const key = `${def.type}:${trigger}`; if (isUsablePrefix(qualified)) table[key] = qualified; else if (qualified !== undefined && qualifier === 'required' && !this.noted.has(key)) { this.noted.add(key); ignored.push(key); - this.note(`prefix of ${key}`, qualified, `is longer than 56 characters (the ${def.type} prefix qualified with the trigger): new ids of such events follow the learned id style, and the rule reports them`, `Shorten the ${def.type} prefix in the .bpmnlintrc, or set eventDefinitionQualifier to optional.`); + this.note(`prefix of ${key}`, qualified, `is longer than 56 characters (the ${def.type} prefix qualified with the trigger): new ids of such events take the plain prefix, and the rule reports them`, `Shorten the ${def.type} prefix in the .bpmnlintrc, or set eventDefinitionQualifier to optional.`); } } } @@ -152,10 +145,10 @@ export class IdConventionsReader { * bpmn-cli names DI after its element, never after a declared prefix, and the rule checks DI ids * too, so a declared DI prefix gets a note. */ - private noteDiPrefixes(moddle: Moddle, prefixes: Prefixes): void { + private noteDiPrefixes(moddle: Moddle, convention: ElementIdConvention): void { for (const type of DI_WITH_IDS) { - const hit = this.naming.resolvePrefix(moddle.create(type, { id: 'x' }), prefixes); - if (hit) this.note(`prefix of ${type}`, hit.prefix, 'is not followed: bpmn-cli names DI after its element (BPMNShape_, _di), and the rule reports new DI ids', 'Set it to false or remove it; bpmn-js names DI _di too.'); + const prefix = convention.prefixOf(moddle.create(type, { id: 'x' })); + if (prefix !== null) this.note(`prefix of ${type}`, prefix, 'is not followed: bpmn-cli names DI after its element (BPMNShape_, _di), and the rule reports new DI ids', 'Set it to false or remove it; bpmn-js names DI _di too.'); } } } diff --git a/src/adapters/out/files/BpmnlintLibs.ts b/src/adapters/out/files/BpmnlintLibs.ts index 62f210fd..c3e2b0dd 100644 --- a/src/adapters/out/files/BpmnlintLibs.ts +++ b/src/adapters/out/files/BpmnlintLibs.ts @@ -9,10 +9,7 @@ export interface BpmnlintPlugin { createBundledResolver(): BpmnlintResolver; /** a host's default without a .bpmnlintrc: recommended rules, a preset and the engine's config */ getDefaultLintConfig(options?: { engine?: 'c7' | 'c8'; preset?: 'modeling' | 'automation' }): BpmnlintConfig; - naming: IdNamingHelpers & { - eventQualifiers(element: unknown): string[]; - isGeneratedId(id: string): boolean; - }; + naming: IdNamingHelpers; } type NodeResolverClass = new (options: { require: Require }) => BpmnlintResolver; diff --git a/src/api.ts b/src/api.ts index 0d41ff81..4dce2762 100644 --- a/src/api.ts +++ b/src/api.ts @@ -60,7 +60,7 @@ export interface EditOptions { file?: string; /** keep existing flow ids when the ops give the flow other ends (`--keep-flow-ids`) */ keepFlowIds?: boolean; - /** new ids follow them where they declare a prefix, the file's own style elsewhere */ + /** new ids follow them where they declare a prefix, bpmn-cli's own prefix for the types they leave out; absent: element-id-naming's defaults */ conventions?: IdConventions; /** include the model view of the result (`--show`) */ show?: boolean; diff --git a/src/application/port/in/ShowConventionsUseCase.ts b/src/application/port/in/ShowConventionsUseCase.ts index b326c7bc..ba2f3ecf 100644 --- a/src/application/port/in/ShowConventionsUseCase.ts +++ b/src/application/port/in/ShowConventionsUseCase.ts @@ -4,7 +4,7 @@ import type { FileBpmnlintOptions } from '../out/LoadProjectSettingsPort.js'; /** * `bpmn show --conventions`: the id conventions new ids follow in a file, - * from its .bpmnlintrc and its own style. + * from its .bpmnlintrc, else element-id-naming's defaults. */ export interface ShowConventionsUseCase { diff --git a/src/application/service/ShowConventionsService.ts b/src/application/service/ShowConventionsService.ts index d354ded3..f7133b87 100644 --- a/src/application/service/ShowConventionsService.ts +++ b/src/application/service/ShowConventionsService.ts @@ -23,6 +23,6 @@ export class ShowConventionsService implements ShowConventionsUseCase { } private static undeclared(found: ProjectIdConventions | undefined, skipped: boolean): Undeclared { - return skipped ? 'skipped' : !found ? 'no config' : found.unavailable ? 'not loaded' : !found.setting || found.setting.severity === 'off' ? 'off' : 'no prefixes'; + return skipped ? 'skipped' : !found ? 'no config' : found.unavailable ? 'not loaded' : 'off'; } } diff --git a/src/domain/batch/EditBatch.ts b/src/domain/batch/EditBatch.ts index ba0198fc..23caa28f 100644 --- a/src/domain/batch/EditBatch.ts +++ b/src/domain/batch/EditBatch.ts @@ -43,22 +43,18 @@ export class EditBatch { */ async run(file = this.doc.file): Promise { return LayoutDebug.during(this.opts.debug, async () => { - try { - await this.guard(); - const asRead = await this.textAsRead(); - const diagram = await BatchDrawing.capture(this.doc, this.commands, this.opts, asRead, EditBatch.removeElements); - const findings = await FindingBaseline.capture(this.doc, { platform: this.opts.platform, force: this.opts.force, validators: this.opts.validators, file: this.opts.file ?? this.doc.file, ops: this.commands }); - const importWarnings = reportedImportWarnings(this.doc).map((warning) => warning.message.split('\n')[0]!); - const { batch, last } = await this.runCommands(diagram); - await this.validateAndDropProfileDuplicates(findings, batch); - const drawn = await diagram.apply(this.edit, batch.commands, batch.changes, last); - const xml = await this.keptText(drawn.xml, asRead, batch.changes); - const layout = drawn.layout; - const validation = await findings.onCandidate(xml); - return await this.result(file, batch, xml, layout, validation, findings.delta(), importWarnings); - } finally { - this.edit.close(); - } + await this.guard(); + const asRead = await this.textAsRead(); + const diagram = await BatchDrawing.capture(this.doc, this.commands, this.opts, asRead, EditBatch.removeElements); + const findings = await FindingBaseline.capture(this.doc, { platform: this.opts.platform, force: this.opts.force, validators: this.opts.validators, file: this.opts.file ?? this.doc.file, ops: this.commands }); + const importWarnings = reportedImportWarnings(this.doc).map((warning) => warning.message.split('\n')[0]!); + const { batch, last } = await this.runCommands(diagram); + await this.validateAndDropProfileDuplicates(findings, batch); + const drawn = await diagram.apply(this.edit, batch.commands, batch.changes, last); + const xml = await this.keptText(drawn.xml, asRead, batch.changes); + const layout = drawn.layout; + const validation = await findings.onCandidate(xml); + return await this.result(file, batch, xml, layout, validation, findings.delta(), importWarnings); }); } @@ -137,8 +133,6 @@ export class EditBatch { private async result(file: string | undefined, batch: BatchRun, xml: string, layout: LayoutStatus, validation: ValidationResult, delta: ValidationDelta | undefined, importWarnings: string[]): Promise { const doc = this.doc; const unchanged = doc.source !== undefined && xml === doc.source.text; - const declared = this.edit.allocator.declaredIds.filter((id) => doc.has(id)); - const conventions = this.edit.conventions; const result: MutationResult = { ok: true, ...(file ? { file } : {}), @@ -151,7 +145,6 @@ export class EditBatch { importWarnings, ...(delta ? { delta } : {}), xml, - ...(declared.length ? { conventions: { ids: [...new Set(declared)], ...(conventions?.source ? { source: conventions.source } : {}) } } : {}), }; if (this.opts.show) { const fresh = await Doc.fromXml(xml, file); diff --git a/src/domain/batch/types.ts b/src/domain/batch/types.ts index b6ca8a16..18dbf5f7 100644 --- a/src/domain/batch/types.ts +++ b/src/domain/batch/types.ts @@ -50,12 +50,4 @@ export interface MutationResult { delta?: ValidationDelta; view?: ModelView; xml: string; - /** generated ids that follow the declared conventions rather than the file's own style */ - conventions?: ConventionsReport; -} - -export interface ConventionsReport { - ids: string[]; - /** e.g. `.bpmnlintrc in models` */ - source?: string; } diff --git a/src/domain/commands/CommandFieldCheck.ts b/src/domain/commands/CommandFieldCheck.ts index b2b2a97f..c1ce1494 100644 --- a/src/domain/commands/CommandFieldCheck.ts +++ b/src/domain/commands/CommandFieldCheck.ts @@ -88,9 +88,9 @@ export class CommandFieldCheck { selection(needed: boolean): void { const obj = this.fields; - if (needed && obj['ids'] === undefined && !this.hasSelector()) throw this.fail('give "ids" and / or a selector ("path", "kind", "branch")', 'Example: {"op":"color","path":["Event_Start","Event_Done"],"color":"green"}.'); + if (needed && obj['ids'] === undefined && !this.hasSelector()) throw this.fail('give "ids" and / or a selector ("path", "kind", "branch")', 'Example: {"op":"color","path":["startEvent_start","endEvent_done"],"color":"green"}.'); const path = obj['path'] as string[] | undefined; - if (path && path.length !== 2) throw this.fail(`"path" takes exactly two ids (from and to), got ${path.length}`, 'Example: "path": ["Event_Start", "Event_Done"], "via": ["Flow_Yes"].'); + if (path && path.length !== 2) throw this.fail(`"path" takes exactly two ids (from and to), got ${path.length}`, 'Example: "path": ["startEvent_start", "endEvent_done"], "via": ["flow_okToBook"].'); if (obj['via'] !== undefined && !path) throw this.fail('"via" needs "path"'); } } diff --git a/src/domain/commands/CommandParser.ts b/src/domain/commands/CommandParser.ts index 7f794989..ad53ab58 100644 --- a/src/domain/commands/CommandParser.ts +++ b/src/domain/commands/CommandParser.ts @@ -230,7 +230,7 @@ export class CommandParser { }); } const list = ops as unknown[]; - if (!list.length) throw usageError('The ops list is empty', { hint: 'Add at least one op, e.g. {"op": "add", "kind": "userTask", "name": "Check invoice", "after": "Event_Start"}.' }); + if (!list.length) throw usageError('The ops list is empty', { hint: 'Add at least one op, e.g. {"op": "add", "kind": "userTask", "name": "Check invoice", "after": "startEvent_orderReceived"}.' }); const parsed = list.map((raw, index) => this.parseOne(raw, index)); this.checkAliases(parsed); return parsed; diff --git a/src/domain/commands/definitions/AlignShapesDefinition.ts b/src/domain/commands/definitions/AlignShapesDefinition.ts index 68aeab38..96790ad7 100644 --- a/src/domain/commands/definitions/AlignShapesDefinition.ts +++ b/src/domain/commands/definitions/AlignShapesDefinition.ts @@ -19,7 +19,7 @@ export class AlignShapesDefinition extends EditCommandDefinition { override check(command: CommandFieldCheck): void { command.selection(true); - if (!command.hasSelector() && (command.fields['ids'] as string[]).length < 2 && command.fields['to'] === undefined) throw command.fail('align needs two ids, or one id and "to"', 'Example: {"op":"align","ids":["Event_A","Event_B"],"axis":"column"}.'); + if (!command.hasSelector() && (command.fields['ids'] as string[]).length < 2 && command.fields['to'] === undefined) throw command.fail('align needs two ids, or one id and "to"', 'Example: {"op":"align","ids":["endEvent_a","endEvent_b"],"axis":"column"}.'); } override schemaRules(): unknown[] { diff --git a/src/domain/commands/definitions/EditExtensionElementsDefinition.ts b/src/domain/commands/definitions/EditExtensionElementsDefinition.ts index 28b45773..f53d5ec5 100644 --- a/src/domain/commands/definitions/EditExtensionElementsDefinition.ts +++ b/src/domain/commands/definitions/EditExtensionElementsDefinition.ts @@ -23,7 +23,7 @@ export class EditExtensionElementsDefinition extends EditCommandDefinition { command.selection(true); if (command.fields['side'] !== undefined) { const with_ = PLACE_GROUPS.flat().filter((placeKey) => command.fields[placeKey] !== undefined); - if (with_.length) throw command.fail(`"side" moves a boundary event to a border of its host; it cannot be combined with ${with_.map((placeKey) => `"${placeKey}"`).join(', ')}`, 'Example: {"op":"place","ids":["Event_Overdue"],"side":"top"}.'); - if (command.hasSelector() || (command.fields['ids'] as string[] | undefined)?.length !== 1) throw command.fail('"side" takes exactly one id (the boundary event)', 'Example: {"op":"place","ids":["Event_Overdue"],"side":"top"}.'); + if (with_.length) throw command.fail(`"side" moves a boundary event to a border of its host; it cannot be combined with ${with_.map((placeKey) => `"${placeKey}"`).join(', ')}`, 'Example: {"op":"place","ids":["event_overdue"],"side":"top"}.'); + if (command.hasSelector() || (command.fields['ids'] as string[] | undefined)?.length !== 1) throw command.fail('"side" takes exactly one id (the boundary event)', 'Example: {"op":"place","ids":["event_overdue"],"side":"top"}.'); return; } command.groups(PLACE_GROUPS, 'nothing to do: give a row ("rowOf", "below" or "above") and/or a column ("columnOf", "after" or "before"), or "side" for a boundary event'); diff --git a/src/domain/commands/definitions/ReorderElementsDefinition.ts b/src/domain/commands/definitions/ReorderElementsDefinition.ts index ae126c80..68fe9c01 100644 --- a/src/domain/commands/definitions/ReorderElementsDefinition.ts +++ b/src/domain/commands/definitions/ReorderElementsDefinition.ts @@ -17,7 +17,7 @@ export class ReorderElementsDefinition extends EditCommandDefinition command.fields[givenKey] !== undefined); if (given.length !== 1) { - throw command.fail(`give exactly one of "flows" (outgoing flows of a node), "lanes" (lanes of a pool / process / parent lane) or "pools" (participants of a collaboration)`, 'Example: {"op":"order","id":"Gateway_Ok","flows":["Flow_OkToFix","Flow_OkToBook"]}, {"op":"order","id":"Participant_X","lanes":["Lane_B","Lane_A"]} or {"op":"order","id":"Collaboration_X","pools":["Participant_Customer","Participant_X"]}.'); + throw command.fail(`give exactly one of "flows" (outgoing flows of a node), "lanes" (lanes of a pool / process / parent lane) or "pools" (participants of a collaboration)`, 'Example: {"op":"order","id":"gateway_ok","flows":["flow_okToFix","flow_okToBook"]}, {"op":"order","id":"participant_x","lanes":["lane_b","lane_a"]} or {"op":"order","id":"collaboration_x","pools":["participant_customer","participant_x"]}.'); } } diff --git a/src/domain/commands/definitions/SetPropertiesDefinition.ts b/src/domain/commands/definitions/SetPropertiesDefinition.ts index 2339c3c8..657ac813 100644 --- a/src/domain/commands/definitions/SetPropertiesDefinition.ts +++ b/src/domain/commands/definitions/SetPropertiesDefinition.ts @@ -19,7 +19,7 @@ export class SetPropertiesDefinition extends EditCommandDefinition | undefined) ?? {}; const unset = (command.fields['unset'] as string[] | undefined) ?? []; - if (!Object.keys(values).length && !unset.length) throw command.fail('nothing to do: "values" is empty and "unset" is missing', 'Example: {"op":"set","id":"Activity_X","values":{"name":"New name"}}.'); + if (!Object.keys(values).length && !unset.length) throw command.fail('nothing to do: "values" is empty and "unset" is missing', 'Example: {"op":"set","id":"task_x","values":{"name":"New name"}}.'); command.fields['values'] = values; } diff --git a/src/domain/commands/utils/op-fields.ts b/src/domain/commands/utils/op-fields.ts index dfbc2749..e9a50ff7 100644 --- a/src/domain/commands/utils/op-fields.ts +++ b/src/domain/commands/utils/op-fields.ts @@ -41,8 +41,8 @@ export const KIND_FIELD = ref('Element kind with optional trigger suffix, e.g. " export const NODE_FIELDS: FieldsOf = { kind: KIND_FIELD, - name: str('Display name. Also drives the generated id (_ in the default style; new ids follow the id style of the file).'), - id: ref('Explicit id (default: a speaking id in the id style of the file: _, unnamed elements _ such as Gateway_AfterCheckInvoice). A later op of the batch refers to the element by this id or by an alias ("as").'), + name: str('Display name. Also drives the generated id ( after the id conventions: userTask_checkInvoice).'), + id: ref('Explicit id (default: an id after the id conventions, element-id-naming of the .bpmnlintrc or its defaults: , unnamed elements such as gateway_afterCheckInvoice). A later op of the batch refers to the element by this id or by an alias ("as").'), as: alias('Batch alias of the new element: "$" + a name, e.g. "$check".'), flowAs: alias('Batch alias of the flow into the new node (the flow the flow options describe; with "before" on a join or an unconnected node: the flow out of it).'), refAs: alias(REF_AS), @@ -100,7 +100,7 @@ export function opsExample(): { ops: EditCommand[] } { ops: [ { op: 'split', - after: 'Activity_CheckInvoice', + after: 'userTask_checkInvoice', kind: 'exclusiveGateway', name: 'Invoice ok?', as: '$ok', @@ -111,7 +111,7 @@ export function opsExample(): { ops: EditCommand[] } { }, { op: 'add', kind: 'boundaryEvent:timer', name: 'Reminder', on: '$clarify', timer: 'P2D', nonInterrupting: true, as: '$reminder' }, { op: 'add', kind: 'sendTask', name: 'Remind customer', after: '$reminder', as: '$remind' }, - { op: 'add', kind: 'endEvent', name: 'Reminder sent', in: 'Process_OrderHandling', as: '$sent' }, + { op: 'add', kind: 'endEvent', name: 'Reminder sent', in: 'orderHandling', as: '$sent' }, { op: 'connect', source: '$remind', target: '$sent' }, { op: 'set', id: '$book', values: { name: 'Book invoice in ERP', doc: 'Posts the invoice to the ledger.' } }, { op: 'ext', id: '$book', action: 'add', type: 'zeebe:taskDefinition', attrs: { type: 'book-invoice', retries: '3' } }, diff --git a/src/domain/diagram/BatchDrawing.ts b/src/domain/diagram/BatchDrawing.ts index 97bdc0f4..f0de462c 100644 --- a/src/domain/diagram/BatchDrawing.ts +++ b/src/domain/diagram/BatchDrawing.ts @@ -156,7 +156,7 @@ export class BatchDrawing { private static noteGivenIds(changes: ChangeSet, givenIds: GivenId[] | undefined): void { if (!givenIds?.length) return; for (const given of givenIds) changes.change({ id: given.id, kind: given.kind, ...(given.name ? { name: given.name } : {}), detail: 'id added (it had none; the diagram refers to elements by id)' }); - changes.note(`${givenIds.length} element(s) without id got one in the file's id style so that the redrawn diagram can show them: ${givenIds.map((given) => given.id).join(', ')}`); + changes.note(`${givenIds.length} element(s) without id got one in the id conventions so that the redrawn diagram can show them: ${givenIds.map((given) => given.id).join(', ')}`); } /** The last layout with what drawBetween drew before it: the first layout's mode and reason, every id and diagram command. */ diff --git a/src/domain/diagram/commands/ElementSelector.ts b/src/domain/diagram/commands/ElementSelector.ts index 9e6e1291..ff6e9e27 100644 --- a/src/domain/diagram/commands/ElementSelector.ts +++ b/src/domain/diagram/commands/ElementSelector.ts @@ -125,8 +125,8 @@ export class ElementSelector { * boundary events follow them. */ select(sel: Selection, opts: { shapesOnly: boolean; drawn: (id: string) => boolean; centreX: (id: string) => number | undefined }): string[] { - if (sel.path && sel.path.length !== 2) throw usageError(`--path takes exactly two ids (from and to), got ${sel.path.length}`, { hint: 'Example: --path Event_Start Event_Done (with --via to choose a branch).' }); - if (sel.via?.length && !sel.path) throw usageError('--via needs --path', { hint: 'Example: --path Event_Start Event_Done --via Flow_Yes.' }); + if (sel.path && sel.path.length !== 2) throw usageError(`--path takes exactly two ids (from and to), got ${sel.path.length}`, { hint: 'Example: --path startEvent_start endEvent_done (with --via to choose a branch).' }); + if (sel.via?.length && !sel.path) throw usageError('--via needs --path', { hint: 'Example: --path startEvent_start endEvent_done --via flow_okToBook.' }); const found: El[] = []; if (sel.branch) found.push(...this.branch(sel.branch, opts.centreX)); if (sel.path) found.push(...this.path(sel.path[0]!, sel.path[1]!, sel.via ?? [])); diff --git a/src/domain/diagram/di/DrawnElementIds.ts b/src/domain/diagram/di/DrawnElementIds.ts index 8b609ca5..d761d77a 100644 --- a/src/domain/diagram/di/DrawnElementIds.ts +++ b/src/domain/diagram/di/DrawnElementIds.ts @@ -103,7 +103,7 @@ export class DrawnElementIds { if (!idless.length) return []; let next = allocate; if (!next) { - const style = IdStyle.infer(this.defs); + const style = IdStyle.of(); const registry = new IdRegistry(indexById(this.defs).keys()); next = (req) => { const { id } = style.next(req, registry); diff --git a/src/domain/diagram/layout/FullLayout.ts b/src/domain/diagram/layout/FullLayout.ts index 53b18bb6..01e9d3f5 100644 --- a/src/domain/diagram/layout/FullLayout.ts +++ b/src/domain/diagram/layout/FullLayout.ts @@ -18,7 +18,7 @@ export interface LayoutOptions { expand?: Iterable; /** sub-process ids that must be collapsed */ collapse?: Iterable; - /** claims an id for a drawn element that has none (default: the file's id style) */ + /** claims an id for a drawn element that has none (default: the id conventions) */ allocateId?: (req: IdRequest) => string; } diff --git a/src/domain/diagram/layout/LayoutDebug.ts b/src/domain/diagram/layout/LayoutDebug.ts index 01568f82..29a1c999 100644 --- a/src/domain/diagram/layout/LayoutDebug.ts +++ b/src/domain/diagram/layout/LayoutDebug.ts @@ -1,4 +1,4 @@ -/** One line without trailing newline, e.g. `[route] Flow_3: source moved`. */ +/** One line without trailing newline, e.g. `[route] flow_aToB: source moved`. */ export type DebugSink = (line: string) => void; /** diff --git a/src/domain/document/ChangeSet.ts b/src/domain/document/ChangeSet.ts index 816e01c7..e7af3f3d 100644 --- a/src/domain/document/ChangeSet.ts +++ b/src/domain/document/ChangeSet.ts @@ -8,7 +8,7 @@ export interface Change { /** canonical kind label, e.g. `userTask`, `startEvent:message`, `sequenceFlow` */ kind: string; name?: string; - /** free-form context, e.g. "after Activity_CheckInvoice", "Event_A -> Activity_B" */ + /** free-form context, e.g. "after userTask_checkInvoice", "startEvent_a -> task_b" */ detail?: string; } diff --git a/src/domain/document/Doc.ts b/src/domain/document/Doc.ts index 9a456d36..cda44ec5 100644 --- a/src/domain/document/Doc.ts +++ b/src/domain/document/Doc.ts @@ -85,8 +85,8 @@ export class Doc { } /** - * The given process id, else one from its name, else Process_1; the definitions speak like the - * process (Definitions_OrderToCash), else the tool default Definitions_1. + * The given process id, else its name (orderToCash), else Process_1; the definitions speak like + * the process (definitions_orderToCash), else the tool default Definitions_1. */ private static rootIds(opts: NewDocOptions, style: IdStyle, ids: IdRegistry): { processId: string; definitionsId: string } { const declared = (type: string): boolean => style.explain(typeRequest(type)).source === 'lint'; @@ -126,7 +126,6 @@ export class Doc { return new Doc(model, ids, file, { text: xml, mirror }); } - /** A new file has no own id style to differ from, so given conventions are taken without a `conventions:` line. */ static create(opts: NewDocOptions = {}, file?: string): Doc { Doc.assertTarget(opts.target); const moddle = createModdle(); @@ -291,7 +290,7 @@ export class Doc { return new IdSuggestions(this, batch).for(query, max); } - /** New ids follow these where they declare a prefix, the file's own style elsewhere. */ + /** New ids follow these, else element-id-naming's defaults. */ get conventions(): IdConventions | undefined { return this.declared; } @@ -302,13 +301,9 @@ export class Doc { this.style = undefined; } - /** Learned from the document's ids on first use, the declared conventions over them. */ - get ownIdStyle(): IdStyle { - return (this.style ??= IdStyle.infer(this.definitions, this.declared)); - } - - forgetIdStyle(): void { - this.style = undefined; + /** The declared conventions, else element-id-naming's defaults. */ + get idStyle(): IdStyle { + return (this.style ??= IdStyle.of(this.declared)); } /** Refuses an invalid or taken id. */ diff --git a/src/domain/document/EditSession.ts b/src/domain/document/EditSession.ts index 56c2c315..939c3c1d 100644 --- a/src/domain/document/EditSession.ts +++ b/src/domain/document/EditSession.ts @@ -96,44 +96,25 @@ export class EditSession { return this.doc.requireScope(id, this.batch); } - newId(prefix: string, name?: string): string { - return this.allocator.allocate({ key: prefix, prefix, typeNames: [prefix.charAt(0).toLowerCase() + prefix.slice(1)], ...(name ? { name } : {}) }).id; - } - get followFlowEnds(): boolean { return this.settings.keepFlowIds === undefined ? this.doc.followFlowEnds : !this.settings.keepFlowIds; } - /** The document's own, or one learned on first use for conventions the call brings itself. */ + /** The document's, or one for conventions the call brings itself. */ get idStyle(): IdStyle { - if (this.style) return this.style; - const own = this.ownConventions; - return own ? (this.style = IdStyle.infer(this.doc.definitions, own)) : this.doc.ownIdStyle; - } - - private get ownConventions(): IdConventions | undefined { const conventions = this.settings.conventions; - return conventions !== undefined && conventions !== this.doc.conventions ? conventions : undefined; + if (conventions === undefined || conventions === this.doc.conventions) return this.doc.idStyle; + return (this.style ??= IdStyle.of(conventions)); } - /** - * Learns the id style now, before an op removes the elements it learns from: a batch whose - * first op removes flows could otherwise read another style from what is left. - */ begin(): void { this.keptFlows.clear(); this.idStates.clear(); this.allocator.begin(); this.formerEnds.clear(); - this.style = this.idStyle; this.flows = this.followFlowEnds ? undefined : new Map(this.doc.allSequenceFlows().map((flow) => [flow, [flow.get('sourceRef'), flow.get('targetRef')]] as const)); } - /** Conventions the call brought itself must not stick: the document relearns its style. */ - close(): void { - if (this.ownConventions) this.doc.forgetIdStyle(); - } - /** With flow ids kept: the flows at begin(), the only ones keeping their id, with their ends. */ get flowsBefore(): ReadonlyMap | undefined { return this.flows; diff --git a/src/domain/document/IdAllocator.ts b/src/domain/document/IdAllocator.ts index 0e174414..f46433aa 100644 --- a/src/domain/document/IdAllocator.ts +++ b/src/domain/document/IdAllocator.ts @@ -6,14 +6,12 @@ import type { Doc } from './Doc.js'; /** * The ids one edit gives out, and what it recorded about them: the id each generated one wanted, - * the ones that took a collision suffix, the ones under a declared prefix. + * the ones that took a collision suffix. */ export class IdAllocator { private suffixedIds: Array<{ id: string; base: string }> = []; /** generated id -> the id it wanted; nobody's API yet, so a later op may still rename it */ private readonly generated = new Map(); - /** ids under a declared prefix that the file's own style would give otherwise */ - private readonly declared: string[] = []; constructor( private readonly doc: Doc, @@ -24,15 +22,10 @@ export class IdAllocator { this.generated.clear(); } - /** - * Claims a new id; `base` is the id the request wanted before a collision suffix. An id under a - * declared prefix that the file's own style would give otherwise goes on the `conventions:` line. - */ + /** Claims a new id; `base` is the id the request wanted before a collision suffix. */ allocate(req: IdRequest): { id: string; base: string } { - const style = this.styleOf(); const ids = this.doc.ids; - const out = style.next(req, ids); - if (style.ownStyleDiffers && style.explain(req).source === 'lint' && out.base !== style.learnedOnly().next(req, ids).base) this.declared.push(out.id); + const out = this.styleOf().next(req, ids); ids.claim(out.id); this.generated.set(out.id, out.base); if (out.id !== out.base) this.suffixedIds.push(out); @@ -69,10 +62,9 @@ export class IdAllocator { * A free id from `base` for a generated id `old` that a retype left with a stale prefix. * Claims nothing: the rename does. */ - regenerated(old: string, base: string, sep: string): string { + regenerated(base: string, sep: string): string { const id = this.suffixed(base, sep); if (id !== base) this.suffixedIds.push({ id, base }); - if (this.declared.includes(old)) this.declared.push(id); return id; } @@ -89,7 +81,7 @@ export class IdAllocator { /** Whether `req` gives `id` in the style, with or without a collision suffix. */ isDerived(id: string, req: IdRequest): boolean { return this.styleOf() - .derivedBases(req, id) + .derivedBases(req) .some((base) => id === base || (id.startsWith(base) && isSuffix(base, id.slice(base.length), 'either'))); } @@ -101,14 +93,6 @@ export class IdAllocator { this.generated.delete(id); } - recordDeclared(id: string): void { - if (this.styleOf().ownStyleDiffers) this.declared.push(id); - } - - get declaredIds(): readonly string[] { - return this.declared; - } - reportSuffixed(cs: ChangeSet): void { const suffixed = this.suffixedIds; this.suffixedIds = []; diff --git a/src/domain/document/IdSuggestions.ts b/src/domain/document/IdSuggestions.ts index d6c52ea3..f544786c 100644 --- a/src/domain/document/IdSuggestions.ts +++ b/src/domain/document/IdSuggestions.ts @@ -34,8 +34,8 @@ export class IdSuggestions { /** * "Did you mean" ids, best first: the batch's rename, another case / umlaut spelling / prefix, * containment, a typo, a matching name. Ids with the query's prefix come first (a mistyped - * `Flow_...` suggests flows), then ids the running batch created. A same-prefix id whose stem - * contains the query's (Flow_GatewayPurchaseApprovedToOrderGoods for Flow_PurchaseApprovedToOrderGoods) + * `flow_...` suggests flows), then ids the running batch created. A same-prefix id whose stem + * contains the query's (flow_gatewayPurchaseApprovedToOrderGoods for flow_purchaseApprovedToOrderGoods) * counts as close. Kind-alike suggestions are not ids, so without hits it returns nothing. */ for(query: string, max = 5): string[] { diff --git a/src/domain/document/Kinds.ts b/src/domain/document/Kinds.ts index 0185bd70..ef2834d3 100644 --- a/src/domain/document/Kinds.ts +++ b/src/domain/document/Kinds.ts @@ -38,8 +38,6 @@ export interface KindDef { type: string; family: Family; aliases: string[]; - /** id prefix (bpmn-js convention) */ - prefix: string; /** allowed triggers (events only) */ triggers?: Trigger[]; /** extra properties set on creation */ @@ -85,35 +83,35 @@ const TRIGGER_ALIASES: Record = { }; export const KINDS: KindDef[] = [ - { kind: 'task', type: 'bpmn:Task', family: 'task', aliases: [], prefix: 'Activity', description: 'Generic task' }, - { kind: 'userTask', type: 'bpmn:UserTask', family: 'task', aliases: ['user'], prefix: 'Activity', description: 'Task performed by a human' }, - { kind: 'serviceTask', type: 'bpmn:ServiceTask', family: 'task', aliases: ['service'], prefix: 'Activity', description: 'Automated task (worker / connector)' }, - { kind: 'scriptTask', type: 'bpmn:ScriptTask', family: 'task', aliases: ['script'], prefix: 'Activity', description: 'Task executing a script' }, - { kind: 'sendTask', type: 'bpmn:SendTask', family: 'task', aliases: ['send'], prefix: 'Activity', description: 'Task sending a message' }, - { kind: 'receiveTask', type: 'bpmn:ReceiveTask', family: 'task', aliases: ['receive'], prefix: 'Activity', description: 'Task waiting for a message' }, - { kind: 'manualTask', type: 'bpmn:ManualTask', family: 'task', aliases: ['manual'], prefix: 'Activity', description: 'Task done outside the engine' }, - { kind: 'businessRuleTask', type: 'bpmn:BusinessRuleTask', family: 'task', aliases: ['rule', 'businessRule', 'decision'], prefix: 'Activity', description: 'Task evaluating a decision / rule' }, - { kind: 'subProcess', type: 'bpmn:SubProcess', family: 'subProcess', aliases: ['sub'], prefix: 'Activity', description: 'Embedded sub-process (expanded by default, use --collapsed to collapse)' }, - { kind: 'eventSubProcess', type: 'bpmn:SubProcess', family: 'subProcess', aliases: ['eventSub'], prefix: 'Activity', props: { triggeredByEvent: true }, description: 'Event sub-process (triggeredByEvent=true); create it as eventSubProcess: (message|timer|signal|conditional|error|escalation|compensate) to get its start event in the same command' }, - { kind: 'adHocSubProcess', type: 'bpmn:AdHocSubProcess', family: 'subProcess', aliases: ['adhoc'], prefix: 'Activity', description: 'Ad-hoc sub-process' }, - { kind: 'transaction', type: 'bpmn:Transaction', family: 'subProcess', aliases: [], prefix: 'Activity', description: 'Transaction sub-process' }, - { kind: 'callActivity', type: 'bpmn:CallActivity', family: 'callActivity', aliases: ['call'], prefix: 'Activity', description: 'Calls another process (set calledElement=)' }, - { kind: 'exclusiveGateway', type: 'bpmn:ExclusiveGateway', family: 'gateway', aliases: ['xor', 'exclusive'], prefix: 'Gateway', branchesByCondition: true, namedAsQuestion: true, description: 'XOR: exactly one outgoing path (name it as a question, name the branches)' }, - { kind: 'parallelGateway', type: 'bpmn:ParallelGateway', family: 'gateway', aliases: ['and', 'parallel'], prefix: 'Gateway', synchronises: true, description: 'AND: all outgoing paths' }, - { kind: 'inclusiveGateway', type: 'bpmn:InclusiveGateway', family: 'gateway', aliases: ['or', 'inclusive'], prefix: 'Gateway', branchesByCondition: true, synchronises: true, namedAsQuestion: true, description: 'OR: one or more outgoing paths' }, - { kind: 'complexGateway', type: 'bpmn:ComplexGateway', family: 'gateway', aliases: ['complex'], prefix: 'Gateway', branchesByCondition: true, synchronises: true, description: 'Complex: a custom split / join rule (no Camunda 7 / 8 engine runs it)' }, - { kind: 'eventBasedGateway', type: 'bpmn:EventBasedGateway', family: 'gateway', aliases: ['eventBased', 'event-based', 'eventGateway'], prefix: 'Gateway', description: 'Waits for the first of several catching events' }, - { kind: 'startEvent', type: 'bpmn:StartEvent', family: 'event', aliases: ['start'], prefix: 'Event', triggers: ['none', 'message', 'timer', 'signal', 'conditional', 'error', 'escalation', 'compensate'], description: 'Start event (error/escalation/compensate only inside an eventSubProcess)' }, - { kind: 'endEvent', type: 'bpmn:EndEvent', family: 'event', aliases: ['end'], prefix: 'Event', triggers: ['none', 'message', 'error', 'signal', 'escalation', 'terminate', 'compensate', 'cancel'], description: 'End event' }, - { kind: 'intermediateCatchEvent', type: 'bpmn:IntermediateCatchEvent', family: 'event', aliases: ['catch', 'icatch', 'catchEvent'], prefix: 'Event', triggers: ['message', 'timer', 'signal', 'conditional', 'link'], description: 'Intermediate catching event (waits)' }, - { kind: 'intermediateThrowEvent', type: 'bpmn:IntermediateThrowEvent', family: 'event', aliases: ['throw', 'ithrow', 'throwEvent'], prefix: 'Event', triggers: ['none', 'message', 'signal', 'escalation', 'link', 'compensate'], description: 'Intermediate throwing event' }, - { kind: 'boundaryEvent', type: 'bpmn:BoundaryEvent', family: 'event', aliases: ['boundary'], prefix: 'Event', triggers: ['message', 'timer', 'error', 'signal', 'escalation', 'conditional', 'compensate', 'cancel'], description: 'Boundary event attached to an activity (--on )' }, - { kind: 'participant', type: 'bpmn:Participant', family: 'participant', aliases: ['pool'], prefix: 'Participant', description: 'Pool; the first one wraps the existing process, further ones get a new process (--black-box for none)' }, - { kind: 'lane', type: 'bpmn:Lane', family: 'lane', aliases: [], prefix: 'Lane', description: 'Lane inside a process (--in )' }, - { kind: 'dataObject', type: 'bpmn:DataObjectReference', family: 'data', aliases: ['data', 'dataObjectReference'], prefix: 'DataObjectReference', description: 'Data object (reference + backing bpmn:DataObject)' }, - { kind: 'dataStore', type: 'bpmn:DataStoreReference', family: 'data', aliases: ['store', 'dataStoreReference'], prefix: 'DataStoreReference', description: 'Data store reference' }, - { kind: 'textAnnotation', type: 'bpmn:TextAnnotation', family: 'artifact', aliases: ['note', 'annotation', 'text'], prefix: 'TextAnnotation', description: 'Text annotation (connect it to an element to draw an association)' }, - { kind: 'group', type: 'bpmn:Group', family: 'artifact', aliases: [], prefix: 'Group', description: 'Group drawn around its members (--members ; the members reference its category value)' }, + { kind: 'task', type: 'bpmn:Task', family: 'task', aliases: [], description: 'Generic task' }, + { kind: 'userTask', type: 'bpmn:UserTask', family: 'task', aliases: ['user'], description: 'Task performed by a human' }, + { kind: 'serviceTask', type: 'bpmn:ServiceTask', family: 'task', aliases: ['service'], description: 'Automated task (worker / connector)' }, + { kind: 'scriptTask', type: 'bpmn:ScriptTask', family: 'task', aliases: ['script'], description: 'Task executing a script' }, + { kind: 'sendTask', type: 'bpmn:SendTask', family: 'task', aliases: ['send'], description: 'Task sending a message' }, + { kind: 'receiveTask', type: 'bpmn:ReceiveTask', family: 'task', aliases: ['receive'], description: 'Task waiting for a message' }, + { kind: 'manualTask', type: 'bpmn:ManualTask', family: 'task', aliases: ['manual'], description: 'Task done outside the engine' }, + { kind: 'businessRuleTask', type: 'bpmn:BusinessRuleTask', family: 'task', aliases: ['rule', 'businessRule', 'decision'], description: 'Task evaluating a decision / rule' }, + { kind: 'subProcess', type: 'bpmn:SubProcess', family: 'subProcess', aliases: ['sub'], description: 'Embedded sub-process (expanded by default, use --collapsed to collapse)' }, + { kind: 'eventSubProcess', type: 'bpmn:SubProcess', family: 'subProcess', aliases: ['eventSub'], props: { triggeredByEvent: true }, description: 'Event sub-process (triggeredByEvent=true); create it as eventSubProcess: (message|timer|signal|conditional|error|escalation|compensate) to get its start event in the same command' }, + { kind: 'adHocSubProcess', type: 'bpmn:AdHocSubProcess', family: 'subProcess', aliases: ['adhoc'], description: 'Ad-hoc sub-process' }, + { kind: 'transaction', type: 'bpmn:Transaction', family: 'subProcess', aliases: [], description: 'Transaction sub-process' }, + { kind: 'callActivity', type: 'bpmn:CallActivity', family: 'callActivity', aliases: ['call'], description: 'Calls another process (set calledElement=)' }, + { kind: 'exclusiveGateway', type: 'bpmn:ExclusiveGateway', family: 'gateway', aliases: ['xor', 'exclusive'], branchesByCondition: true, namedAsQuestion: true, description: 'XOR: exactly one outgoing path (name it as a question, name the branches)' }, + { kind: 'parallelGateway', type: 'bpmn:ParallelGateway', family: 'gateway', aliases: ['and', 'parallel'], synchronises: true, description: 'AND: all outgoing paths' }, + { kind: 'inclusiveGateway', type: 'bpmn:InclusiveGateway', family: 'gateway', aliases: ['or', 'inclusive'], branchesByCondition: true, synchronises: true, namedAsQuestion: true, description: 'OR: one or more outgoing paths' }, + { kind: 'complexGateway', type: 'bpmn:ComplexGateway', family: 'gateway', aliases: ['complex'], branchesByCondition: true, synchronises: true, description: 'Complex: a custom split / join rule (no Camunda 7 / 8 engine runs it)' }, + { kind: 'eventBasedGateway', type: 'bpmn:EventBasedGateway', family: 'gateway', aliases: ['eventBased', 'event-based', 'eventGateway'], description: 'Waits for the first of several catching events' }, + { kind: 'startEvent', type: 'bpmn:StartEvent', family: 'event', aliases: ['start'], triggers: ['none', 'message', 'timer', 'signal', 'conditional', 'error', 'escalation', 'compensate'], description: 'Start event (error/escalation/compensate only inside an eventSubProcess)' }, + { kind: 'endEvent', type: 'bpmn:EndEvent', family: 'event', aliases: ['end'], triggers: ['none', 'message', 'error', 'signal', 'escalation', 'terminate', 'compensate', 'cancel'], description: 'End event' }, + { kind: 'intermediateCatchEvent', type: 'bpmn:IntermediateCatchEvent', family: 'event', aliases: ['catch', 'icatch', 'catchEvent'], triggers: ['message', 'timer', 'signal', 'conditional', 'link'], description: 'Intermediate catching event (waits)' }, + { kind: 'intermediateThrowEvent', type: 'bpmn:IntermediateThrowEvent', family: 'event', aliases: ['throw', 'ithrow', 'throwEvent'], triggers: ['none', 'message', 'signal', 'escalation', 'link', 'compensate'], description: 'Intermediate throwing event' }, + { kind: 'boundaryEvent', type: 'bpmn:BoundaryEvent', family: 'event', aliases: ['boundary'], triggers: ['message', 'timer', 'error', 'signal', 'escalation', 'conditional', 'compensate', 'cancel'], description: 'Boundary event attached to an activity (--on )' }, + { kind: 'participant', type: 'bpmn:Participant', family: 'participant', aliases: ['pool'], description: 'Pool; the first one wraps the existing process, further ones get a new process (--black-box for none)' }, + { kind: 'lane', type: 'bpmn:Lane', family: 'lane', aliases: [], description: 'Lane inside a process (--in )' }, + { kind: 'dataObject', type: 'bpmn:DataObjectReference', family: 'data', aliases: ['data', 'dataObjectReference'], description: 'Data object (reference + backing bpmn:DataObject)' }, + { kind: 'dataStore', type: 'bpmn:DataStoreReference', family: 'data', aliases: ['store', 'dataStoreReference'], description: 'Data store reference' }, + { kind: 'textAnnotation', type: 'bpmn:TextAnnotation', family: 'artifact', aliases: ['note', 'annotation', 'text'], description: 'Text annotation (connect it to an element to draw an association)' }, + { kind: 'group', type: 'bpmn:Group', family: 'artifact', aliases: [], description: 'Group drawn around its members (--members ; the members reference its category value)' }, ]; const REJECTED_TYPES: Record = { diff --git a/src/domain/document/ids/ConventionsTable.ts b/src/domain/document/ids/ConventionsTable.ts index ea09dbfc..6b09d4ea 100644 --- a/src/domain/document/ids/ConventionsTable.ts +++ b/src/domain/document/ids/ConventionsTable.ts @@ -2,15 +2,15 @@ import type { BpmnlintSeverity } from './bpmnlint-rule-setting.js'; import type { Doc } from '../Doc.js'; import { KINDS } from '../Kinds.js'; import type { ValidatorFinding } from '../Finding.js'; -import { IdStyle, type FlowForm, type PrefixSource } from './IdStyle.js'; -import type { IdConventions, QualifierMode } from './IdConventions.js'; +import { IdStyle, type PrefixSource } from './IdStyle.js'; +import { DEFAULT_ID_CONVENTIONS, type IdConventions, type QualifierMode } from './IdConventions.js'; import { kindRequest, typeRequest, type IdRequest } from '../utils/requests.js'; /** * Notes event triggers whose required qualified prefix new ids cannot have: - * they follow the file's style. + * they take the plain prefix. */ -export const UNQUALIFIED_NOTE = "qualified prefix ignored, the file's style (W_CONFIG_IGNORED)"; +export const UNQUALIFIED_NOTE = 'qualified prefix ignored, new ids take the plain one (W_CONFIG_IGNORED)'; export interface ConventionsRow { /** `serviceTask_{name:camel}`; `_{name:camel}` for kinds whose prefix is their own name */ @@ -21,7 +21,7 @@ export interface ConventionsRow { } /** Why no id conventions are declared. */ -export type Undeclared = 'skipped' | 'no config' | 'not loaded' | 'off' | 'no prefixes'; +export type Undeclared = 'skipped' | 'no config' | 'not loaded' | 'off'; /** Options of `conventionsXml`: the conventions to show and where they come from. */ export interface ConventionsXmlOptions { @@ -50,11 +50,10 @@ const IGNORED_NOTE = 'checked; prefix ignored (W_CONFIG_IGNORED)'; /** * The conventions new ids follow in a file (`bpmn show --conventions`): - * the declared id conventions over the file's own style, or why none are declared. + * the declared id conventions, else element-id-naming's defaults and why nothing is declared. */ export class ConventionsTable { private readonly style: IdStyle; - private readonly declared: IdConventions | undefined; /** a prefix that is the kind's own name is one row for all such kinds (_) */ private readonly groups: Array = []; @@ -62,22 +61,21 @@ export class ConventionsTable { private readonly doc: Doc | undefined, private readonly conventions: IdConventions | undefined, ) { - this.style = doc ? IdStyle.infer(doc.definitions, conventions) : IdStyle.of(conventions); - this.declared = this.style.declared ? conventions : undefined; + this.style = IdStyle.of(conventions); for (const def of KINDS) this.element(def.kind, def.type, kindRequest(def)); for (const type of ['bpmn:Process', 'bpmn:Collaboration', 'bpmn:Message']) this.element(type.charAt(5).toLowerCase() + type.slice(6), type, typeRequest(type)); for (const [kind, type] of [['sequenceFlow', 'bpmn:SequenceFlow'], ['messageFlow', 'bpmn:MessageFlow']] as const) this.flow(kind, type); } - /** A row of one kind shows its concrete pattern (Process_{name:pascal}, not _). */ + /** A row of one kind shows its concrete pattern (process_{name:camel}, not _). */ view(opts: { file?: string; severity?: BpmnlintSeverity; notes?: ValidatorFinding[]; where?: string; undeclared?: Undeclared } = {}): ConventionsView { - const declared = this.declared; - const conventions = this.conventions; + const declared = this.conventions; + const followed = declared ?? DEFAULT_ID_CONVENTIONS; const rows: ConventionsRow[] = this.groups.map(({ concrete, ...row }) => (row.kinds.length === 1 ? { ...row, pattern: concrete } : row)); - const examples = ['bpmn:StartEvent:timer', 'bpmn:BoundaryEvent:message', 'bpmn:EndEvent:error'].map((key) => declared?.prefixes[key]).filter((prefix): prefix is string => !!prefix); - const mode = declared?.qualifier ?? 'optional'; - const source = conventions?.source ?? opts.where; - const undeclared = declared ? undefined : (opts.undeclared ?? (conventions ? 'no prefixes' : undefined)); + const examples = ['bpmn:StartEvent:timer', 'bpmn:BoundaryEvent:message', 'bpmn:EndEvent:error'].map((key) => followed.prefixes[key]).filter((prefix): prefix is string => !!prefix); + const mode = followed.qualifier ?? 'optional'; + const source = declared?.source ?? opts.where; + const undeclared = declared ? undefined : opts.undeclared; return { ...(opts.file !== undefined ? { file: opts.file } : {}), exists: !!this.doc, @@ -85,7 +83,7 @@ export class ConventionsTable { ...(declared ? { rule: { severity: opts.severity ?? 'warn', case: declared.case ?? 'any', qualifier: mode } } : {}), ...(undeclared ? { undeclared } : {}), rows, - ...(declared && mode !== 'off' && examples.length ? { qualified: { mode, examples } } : {}), + ...(mode !== 'off' && examples.length ? { qualified: { mode, examples } } : {}), notes: opts.notes ?? [], }; } @@ -100,45 +98,25 @@ export class ConventionsTable { const { head, source, body } = this.style.explain(req); const own = head.toLowerCase() === `${kind.toLowerCase()}_` ? (/^[a-z]/.test(head) ? '_' : '_') : head; const tail = `{name:${body}}`; - this.add(kind, `${head}${tail}`, `${own}${tail}`, source, source !== 'lint' ? this.unnamed(type) : this.unqualified(type)); + const note = source === 'bpmn-cli' ? this.unchecked(type, head) : this.unqualified(type); + this.add(kind, `${head}${tail}`, `${own}${tail}`, source, note); } - /** - * Message flows the rule does not name take the sequence flows' prefix, unchecked. A long head - * is noted with its fallback (Flow_) where the ends do not fit beside it. - */ private flow(kind: 'sequenceFlow' | 'messageFlow', type: 'bpmn:SequenceFlow' | 'bpmn:MessageFlow'): void { - const flowExplanation = this.style.explainFlow(type); - const borrowed = kind === 'messageFlow' && flowExplanation.source === 'lint' && !this.declared?.prefixes['bpmn:MessageFlow']; - const pattern = `${flowExplanation.head}${this.flowTail(flowExplanation.form, flowExplanation.style.scope)}`; - const notes = [borrowed || flowExplanation.source !== 'lint' ? this.unnamed(type) : undefined, flowExplanation.fallback ? `${flowExplanation.fallback} where the ends do not fit` : undefined].filter((note): note is string => !!note); - this.add(kind, pattern, pattern, borrowed ? 'lint shape' : flowExplanation.source, notes.length ? notes.join('; ') : undefined); + const { head, source, body } = this.style.explain(typeRequest(type)); + const join = body === 'snake' ? '_to_' : body === 'kebab' ? '-to-' : 'To'; + const pattern = `${head}{source}${join}{target}`; + this.add(kind, pattern, pattern, source, source === 'bpmn-cli' ? this.unchecked(type, head) : undefined); } - private unnamed(type: string): string | undefined { - const declared = this.declared; - return declared ? (declared.ignored?.includes(type) ? IGNORED_NOTE : 'not checked') : undefined; + /** A named process takes its name alone, an unnamed one Process_1. */ + private unchecked(type: string, head: string): string { + const checked = this.conventions?.ignored?.includes(type) ? IGNORED_NOTE : 'not checked'; + return head === '' ? `${checked}; unnamed: Process_1` : checked; } private unqualified(type: string): string | undefined { - const triggers = (this.declared?.ignored ?? []).filter((key) => key.startsWith(`${type}:`)).map((key) => key.slice(type.length + 1)); + const triggers = (this.conventions?.ignored ?? []).filter((key) => key.startsWith(`${type}:`)).map((key) => key.slice(type.length + 1)); return triggers.length ? `${triggers.join(', ')}: ${UNQUALIFIED_NOTE}` : undefined; } - - private flowTail(form: FlowForm, scope: string | undefined): string { - switch (form) { - case 'idPair': - return '{sourceId}_{targetId}'; - case 'idSnake': - return '{sourceId}_to_{targetId}'; - case 'stemTo': - return '{sourceStem}To{TargetStem}'; - case 'stemSnake': - return '{source_stem}_to_{target_stem}'; - case 'scopedTo': - return `${scope}_{SourceWord}To{TargetWord}`; - default: - return '{source}To{target}'; - } - } } diff --git a/src/domain/document/ids/DeclaredConventions.ts b/src/domain/document/ids/DeclaredConventions.ts index bcbc4c45..e2ad5383 100644 --- a/src/domain/document/ids/DeclaredConventions.ts +++ b/src/domain/document/ids/DeclaredConventions.ts @@ -1,63 +1,38 @@ -import { KINDS } from '../Kinds.js'; import { isUsablePrefix } from '../utils/slug.js'; -import { PREFIXED, prefixClass } from '../utils/id-words.js'; -import { kindTypeNames } from '../utils/requests.js'; import type { IdConventions } from './IdConventions.js'; import type { Body, IdState } from './IdStyle.js'; -/** The declared prefixes as votes on the shape of a prefix nothing declares. */ -export interface Votes { - kind: number; - literal: number; - lower: number; - upper: number; -} - -const BODY_OF_CASE: Record = { camelCase: 'camel', PascalCase: 'pascal', snake_case: 'snake' }; +const BODY_OF_CASE: Record = { camelCase: 'camel', PascalCase: 'pascal', snake_case: 'snake', 'kebab-case': 'kebab' }; /** The bodies element-id-naming accepts after the prefix under a declared case. */ -const CASE_BODY: Partial> = { camel: /^[a-z][A-Za-z0-9]*$/, pascal: /^[A-Z][A-Za-z0-9]*$/, snake: /^[a-z0-9]+(_[a-z0-9]+)*$/ }; +const CASE_BODY: Record = { camel: /^[a-z][A-Za-z0-9]*$/, pascal: /^[A-Z][A-Za-z0-9]*$/, snake: /^[a-z0-9]+(_[a-z0-9]+)*$/, kebab: /^[a-z0-9]+(-[a-z0-9]+)*$/ }; /** * The declared id conventions as the style uses them: the usable prefixes and the body case, * checked the way element-id-naming checks them. */ export class DeclaredConventions { - readonly case: Body | undefined; - private shapeVotes: Votes | undefined; - private constructor( readonly prefixes: Record, readonly qualifier: 'off' | 'optional' | 'required', - body: Body | undefined, - /** - * types (`bpmn:ReceiveTask`) and event triggers (`bpmn:EndEvent:escalation`) the rule - * checks with a prefix new ids cannot have - */ - readonly ignored: Set, - ) { - this.case = body; - } + /** undefined: `any`, the body is not checked */ + readonly bodyCase: Body | undefined, + /** event triggers (`bpmn:EndEvent:escalation`) whose required qualified prefix new ids cannot have */ + private readonly ignored: Set, + ) {} - static of(conventions: IdConventions | undefined): DeclaredConventions | undefined { + static of(conventions: IdConventions): DeclaredConventions { const prefixes: Record = {}; - for (const [key, prefix] of Object.entries(conventions?.prefixes ?? {})) if (isUsablePrefix(prefix)) prefixes[key] = prefix; - if (!Object.keys(prefixes).length) return undefined; - const body = conventions?.case ? BODY_OF_CASE[conventions.case] : undefined; - return new DeclaredConventions(prefixes, conventions?.qualifier ?? 'optional', body, new Set(conventions?.ignored)); + for (const [key, prefix] of Object.entries(conventions.prefixes)) if (isUsablePrefix(prefix)) prefixes[key] = prefix; + const body = conventions.case ? BODY_OF_CASE[conventions.case] : undefined; + return new DeclaredConventions(prefixes, conventions.qualifier ?? 'optional', body, new Set(conventions.ignored)); } - /** - * Under `required` an event with a trigger takes the qualified prefix (timerStartEvent_); - * undefined where that one is ignored, so such events follow the learned style. - */ + /** Under `required` an event with a trigger takes the qualified prefix (timerStartEvent_) where it has one. */ headFor(type: string, trigger: string | undefined): string | undefined { const plain = this.prefixes[type]; if (plain === undefined) return undefined; - if (this.qualifier === 'required' && trigger && trigger !== 'none') { - const key = `${type}:${trigger}`; - return this.prefixes[key] ?? (this.ignored.has(key) ? undefined : plain); - } + if (this.qualifier === 'required' && trigger && trigger !== 'none') return this.prefixes[`${type}:${trigger}`] ?? plain; return plain; } @@ -74,20 +49,6 @@ export class DeclaredConventions { return this.qualifier === 'required' ? [qualified] : [plain, qualified]; } - /** Message flows without an entry of their own borrow the sequence flows' prefix. */ - borrowedHead(type: string): string | undefined { - return type !== 'bpmn:SequenceFlow' && this.prefixes[type] === undefined ? this.prefixes['bpmn:SequenceFlow'] : undefined; - } - - /** - * Where the rule checks an element with a prefix new ids cannot have, it rejects every id - * in practice: no id starts with a prefix that cannot start one. - */ - rejects(state: IdState, id: string, heads = this.headsOf(state)): boolean { - if (heads.length) return !this.passes(heads, id); - return this.ignored.has(state.type) || this.prefixes[state.type] !== undefined; - } - acceptsId(id: string, state: IdState): boolean { const heads = this.headsOf(state); return !heads.length || heads.some((head) => id.startsWith(head)); @@ -102,30 +63,17 @@ export class DeclaredConventions { /** acceptsId, and the body is in the declared case too. */ inDeclaredCase(id: string, state: IdState): boolean { const heads = this.headsOf(state); - return !heads.length || this.passes(heads, id); - } - - /** A prefix several kinds share (gateway_) votes once. */ - get votes(): Votes { - if (this.shapeVotes) return this.shapeVotes; - const votes = { kind: 0, literal: 0, lower: 0, upper: 0 }; - const voted = new Set(); - for (const def of KINDS) { - const match = PREFIXED.exec(`${this.prefixes[def.type] ?? ''}x`); - if (!match || voted.has(match[1]!)) continue; - voted.add(match[1]!); - if (prefixClass(match[1]!, kindTypeNames(def), undefined, def.prefix) === 'literal') votes.literal++; - else votes.kind++; - if (/^[a-z]/.test(match[1]!)) votes.lower++; - else votes.upper++; - } - return (this.shapeVotes = votes); + if (!heads.length) return true; + const head = this.headOf(id, state); + if (head === undefined) return false; + return !this.bodyCase || CASE_BODY[this.bodyCase].test(id.slice(head.length)); } - private passes(heads: string[], id: string): boolean { - const head = heads.filter((candidate) => id.startsWith(candidate)).sort((first, second) => second.length - first.length)[0]; - if (head === undefined) return false; - const pattern = this.case && CASE_BODY[this.case]; - return !pattern || pattern.test(id.slice(head.length)); + /** Most declared prefixes begin upper case (Activity_), so bpmn-cli's own ones do too (Message_). */ + get upperFirst(): boolean { + const heads = new Set(Object.values(this.prefixes)); + let upper = 0; + for (const head of heads) if (/^[A-Z]/.test(head)) upper++; + return upper * 2 > heads.size; } } diff --git a/src/domain/document/ids/ElementLabels.ts b/src/domain/document/ids/ElementLabels.ts index f1245be0..d3bb1990 100644 --- a/src/domain/document/ids/ElementLabels.ts +++ b/src/domain/document/ids/ElementLabels.ts @@ -85,8 +85,8 @@ export class ElementLabels { } /** - * An unnamed join named after its split (Gateway_AfterCheck_join -> `Check Join`; - * Gateway_InvoiceOk_join keeps `InvoiceOk_join`). + * An unnamed join named after its split (gateway_afterCheckJoin -> `Check Join`; + * gateway_invoiceOkJoin keeps `invoiceOkJoin`). */ private joinLabel(own: string): string | undefined { const match = /^(.+?)_?join$/i.exec(own); diff --git a/src/domain/document/ids/IdConventions.ts b/src/domain/document/ids/IdConventions.ts index 808d0c77..54859247 100644 --- a/src/domain/document/ids/IdConventions.ts +++ b/src/domain/document/ids/IdConventions.ts @@ -4,18 +4,113 @@ export type QualifierMode = 'off' | 'optional' | 'required'; export interface IdConventions { /** * the prefix of new ids per BPMN type ('bpmn:ServiceTask': 'serviceTask_') and per event - * trigger ('bpmn:StartEvent:timer': 'timerStartEvent_'); other types follow the file's style + * trigger ('bpmn:StartEvent:timer': 'timerStartEvent_'); other types take bpmn-cli's own + * prefix (UNCHECKED_PREFIXES) */ prefixes: Record; /** under `required` a new event with a trigger takes the qualified prefix (default optional) */ qualifier?: QualifierMode; - /** the case of the body after a declared prefix; absent: the file's case */ - case?: 'camelCase' | 'PascalCase' | 'snake_case'; + /** the case of the body after the prefix; absent (`any`): camelCase, unchecked */ + case?: 'camelCase' | 'PascalCase' | 'snake_case' | 'kebab-case'; /** where they come from (`.bpmnlintrc in models`) */ source?: string; /** * types and event triggers (`bpmn:EndEvent:escalation`) checked with a prefix new ids cannot - * follow: they follow the file's style + * follow: they take bpmn-cli's own prefix, or the plain one */ ignored?: string[]; } + +const GATEWAY = 'gateway_'; +const EVENT = 'event_'; +const SUB_PROCESS = 'subProcess_'; +const ASSOCIATION = 'association_'; + +/** + * element-id-naming's defaults (@miragon/bpmnlint-plugin-rules), resolved per concrete type: new + * ids follow them where no .bpmnlintrc declares other conventions. A guard test keeps them in + * step with the plugin. + */ +export const DEFAULT_ID_CONVENTIONS: IdConventions = { + prefixes: { + 'bpmn:StartEvent': 'startEvent_', + 'bpmn:EndEvent': 'endEvent_', + 'bpmn:BoundaryEvent': EVENT, + 'bpmn:IntermediateCatchEvent': EVENT, + 'bpmn:IntermediateThrowEvent': EVENT, + 'bpmn:Task': 'task_', + 'bpmn:ServiceTask': 'serviceTask_', + 'bpmn:UserTask': 'userTask_', + 'bpmn:SendTask': 'sendTask_', + 'bpmn:ReceiveTask': 'receiveTask_', + 'bpmn:ManualTask': 'manualTask_', + 'bpmn:ScriptTask': 'scriptTask_', + 'bpmn:BusinessRuleTask': 'businessRuleTask_', + 'bpmn:CallActivity': 'callActivity_', + 'bpmn:SubProcess': SUB_PROCESS, + 'bpmn:AdHocSubProcess': SUB_PROCESS, + 'bpmn:Transaction': SUB_PROCESS, + 'bpmn:ExclusiveGateway': GATEWAY, + 'bpmn:ParallelGateway': GATEWAY, + 'bpmn:InclusiveGateway': GATEWAY, + 'bpmn:EventBasedGateway': GATEWAY, + 'bpmn:ComplexGateway': GATEWAY, + 'bpmn:SequenceFlow': 'flow_', + 'bpmn:MessageFlow': 'messageFlow_', + 'bpmn:Collaboration': 'collaboration_', + 'bpmn:Participant': 'participant_', + 'bpmn:Lane': 'lane_', + 'bpmn:DataObjectReference': 'dataObject_', + 'bpmn:DataStoreReference': 'dataStore_', + 'bpmn:TextAnnotation': 'textAnnotation_', + 'bpmn:Group': 'group_', + 'bpmn:Association': ASSOCIATION, + 'bpmn:DataInputAssociation': ASSOCIATION, + 'bpmn:DataOutputAssociation': ASSOCIATION, + 'bpmn:StartEvent:message': 'messageStartEvent_', + 'bpmn:StartEvent:timer': 'timerStartEvent_', + 'bpmn:StartEvent:signal': 'signalStartEvent_', + 'bpmn:StartEvent:conditional': 'conditionalStartEvent_', + 'bpmn:StartEvent:error': 'errorStartEvent_', + 'bpmn:StartEvent:escalation': 'escalationStartEvent_', + 'bpmn:StartEvent:compensate': 'compensateStartEvent_', + 'bpmn:EndEvent:message': 'messageEndEvent_', + 'bpmn:EndEvent:error': 'errorEndEvent_', + 'bpmn:EndEvent:signal': 'signalEndEvent_', + 'bpmn:EndEvent:escalation': 'escalationEndEvent_', + 'bpmn:EndEvent:terminate': 'terminateEndEvent_', + 'bpmn:EndEvent:compensate': 'compensateEndEvent_', + 'bpmn:EndEvent:cancel': 'cancelEndEvent_', + 'bpmn:IntermediateCatchEvent:message': 'messageEvent_', + 'bpmn:IntermediateCatchEvent:timer': 'timerEvent_', + 'bpmn:IntermediateCatchEvent:signal': 'signalEvent_', + 'bpmn:IntermediateCatchEvent:conditional': 'conditionalEvent_', + 'bpmn:IntermediateCatchEvent:link': 'linkEvent_', + 'bpmn:IntermediateThrowEvent:message': 'messageEvent_', + 'bpmn:IntermediateThrowEvent:signal': 'signalEvent_', + 'bpmn:IntermediateThrowEvent:escalation': 'escalationEvent_', + 'bpmn:IntermediateThrowEvent:link': 'linkEvent_', + 'bpmn:IntermediateThrowEvent:compensate': 'compensateEvent_', + 'bpmn:BoundaryEvent:message': 'messageEvent_', + 'bpmn:BoundaryEvent:timer': 'timerEvent_', + 'bpmn:BoundaryEvent:error': 'errorEvent_', + 'bpmn:BoundaryEvent:signal': 'signalEvent_', + 'bpmn:BoundaryEvent:escalation': 'escalationEvent_', + 'bpmn:BoundaryEvent:conditional': 'conditionalEvent_', + 'bpmn:BoundaryEvent:compensate': 'compensateEvent_', + 'bpmn:BoundaryEvent:cancel': 'cancelEvent_', + }, + qualifier: 'optional', + case: 'camelCase', +}; + +/** + * bpmn-cli's own prefix for a type element-id-naming does not check, also where a declaration + * leaves a type out or switches it off, is its lcfirst local name (`message_`, `laneSet_`, + * `timerEventDefinition_`), except for these. A named process takes no prefix at all: its id is + * the deployment key. + */ +export const UNCHECKED_PREFIXES: Record = { + 'bpmn:DataObject': 'dataObjectItem_', + 'bpmn:DataStore': 'dataStoreItem_', +}; diff --git a/src/domain/document/ids/IdFitter.ts b/src/domain/document/ids/IdFitter.ts index ee6a33cd..9212ce61 100644 --- a/src/domain/document/ids/IdFitter.ts +++ b/src/domain/document/ids/IdFitter.ts @@ -1,11 +1,6 @@ -import { camelSlug, idWords, isFiller, keptTail, MAX_ID, nameWords, pascalSnakeSlug, readsApart, slugify, snakeSlug, withLetter } from '../utils/slug.js'; -import { beginAlike, lcfirst, speakingStem, stem, ucfirst } from '../utils/id-words.js'; +import { camelSlug, idWords, isFiller, kebabSlug, keptTail, slugify, snakeSlug, readsApart, withLetter } from '../utils/slug.js'; +import { beginAlike } from '../utils/id-words.js'; import type { Body } from './IdStyle.js'; -import type { IdRequest } from '../utils/requests.js'; - -export type IdForm = 'stemTo' | 'idPair' | 'stemSnake' | 'idSnake'; -export const FORM_JOIN: Record = { stemTo: 'To', idPair: '_', stemSnake: '_to_', idSnake: '_to_' }; -export const ID_FORMS = Object.keys(FORM_JOIN) as IdForm[]; interface EndForm { text: string; @@ -24,107 +19,30 @@ export class IdFitter { constructor(opts: { body?: Body; suffixSep?: string; names?: string[] } = {}) { this.body = opts.body ?? 'pascal'; - this.closes = (opts.suffixSep ?? '_') === '' ? /^[_.-]*\d+$/ : /^_\d+$/; + const suffixSep = opts.suffixSep ?? '_'; + this.closes = suffixSep === '' ? /^[_.-]*\d+$/ : new RegExp(`^${suffixSep}\\d+$`); this.names = opts.names; } - ends(source: string, target: string, glued: boolean): [string, string, string] { + ends(source: string, target: string): [string, string, string] { switch (this.body) { - case 'camel': { - const sourceSlug = camelSlug(source); - return [glued ? ucfirst(sourceSlug) : sourceSlug, 'To', slugify(target)]; - } + case 'camel': + return [camelSlug(source), 'To', slugify(target)]; case 'snake': return [snakeSlug(source), '_to_', snakeSlug(target)]; - case 'pascalSnake': - return [pascalSnakeSlug(source), '_To_', pascalSnakeSlug(target)]; + case 'kebab': + return [kebabSlug(source), '-to-', kebabSlug(target)]; default: return [slugify(source), 'To', slugify(target)]; } } - /** - * `long`: the head leaves the ends fewer than 16 characters, so it stays only where they fit - * beside it at whole words (else Flow_). `glued`: a lower-case prefix - * without separator glues the words too (flowCheckToShip); an upper-case one reads apart (F_CheckToShip). - */ - namedHead(prefix: string, sep: string | undefined): { head: string; glued: boolean; long: boolean } { - const glued = sep === '' && /[a-z]/.test(prefix); - const joined = this.body === 'snake' || this.body === 'pascalSnake' ? 4 : 2; - return { head: `${prefix}${glued ? '' : '_'}`, glued, long: MAX_ID - prefix.length - (glued ? 0 : 1) - joined < 16 }; - } - pair(head: string, sourceEnd: string, join: string, targetEnd: string, max: number): string { const room = max - head.length - join.length; const [fittedSource, fittedTarget] = this.fitPair(sourceEnd, targetEnd, Math.max(room, 0)); return `${head}${fittedSource}${join}${fittedTarget}`; } - whole(head: string, sourceEnd: string, join: string, targetEnd: string, max: number): string | undefined { - const pair = this.wholePair(sourceEnd, targetEnd, max - head.length - join.length); - return pair && `${head}${pair[0]}${join}${pair[1]}`; - } - - /** - * idPair and idSnake keep each end's prefix (`Task_`) and share the room after it; under 16 - * characters the whole ids share it. - */ - idForm(form: IdForm, head: string, req: IdRequest, max: number): string { - const sourceId = req.source!; - const targetId = req.target!; - const join = FORM_JOIN[form]; - if (form === 'idPair' || form === 'idSnake') { - const pa = sourceId.length - stem(sourceId).length; - const pb = targetId.length - stem(targetId).length; - const room = max - head.length - join.length - pa - pb; - const fitter = this.ofForm(form); - if (room < 16) return fitter.pair(head, sourceId, join, targetId, max); - const [fittedSource, fittedTarget] = fitter.fitPair(stem(sourceId), stem(targetId), room); - return `${head}${sourceId.slice(0, pa)}${fittedSource}${join}${targetId.slice(0, pb)}${fittedTarget}`; - } - const { sourceEnd, targetEnd, names } = this.stemEnds(form, req); - return this.ofForm(form, names).pair(head, sourceEnd, join, targetEnd, max); - } - - wholeIdForm(form: IdForm, head: string, req: IdRequest, max: number): string | undefined { - const sourceId = req.source!; - const targetId = req.target!; - const join = FORM_JOIN[form]; - if (form === 'idPair' || form === 'idSnake') { - const [pa, pb] = [sourceId.length - stem(sourceId).length, targetId.length - stem(targetId).length]; - const pair = this.ofForm(form).wholePair(stem(sourceId), stem(targetId), max - head.length - join.length - pa - pb); - return pair && `${head}${sourceId.slice(0, pa)}${pair[0]}${join}${targetId.slice(0, pb)}${pair[1]}`; - } - const { sourceEnd, targetEnd, names } = this.stemEnds(form, req); - return this.ofForm(form, names).whole(head, sourceEnd, join, targetEnd, max); - } - - /** An end's id stem when that says something, else its label. */ - stemEnds(form: 'stemTo' | 'stemSnake', req: IdRequest): { sourceEnd: string; targetEnd: string; names?: string[] } { - const slug = (text: string): string => (form === 'stemTo' ? camelSlug(text) : snakeSlug(text)); - const byLabel = (id: string, label: string | undefined): label is string => speakingStem(id) === undefined && !!label && !!slug(label); - const end = (id: string, label: string | undefined): string => speakingStem(id) ?? (byLabel(id, label) ? slug(label) : stem(id)); - const [sourceEnd, targetEnd] = [end(req.source!, req.sourceLabel), end(req.target!, req.targetLabel)]; - if (form === 'stemSnake') return { sourceEnd, targetEnd }; - return { sourceEnd: lcfirst(sourceEnd), targetEnd: ucfirst(targetEnd), ...(byLabel(req.source!, req.sourceLabel) ? { names: nameWords(req.sourceLabel) } : {}) }; - } - - /** - * The first word with a letter after prefix and scope (`serviceTask_KotO_ValidatePayment` -> - * Validate): a number would end the flow's id like a collision suffix (`To4`). - */ - endWord(id: string, scope: string, terminal: 'Start' | 'End'): string { - const segments = id.split('_'); - if (segments.length > 1 && new RegExp(`^${terminal}(Event)?$`, 'i').test(segments[0]!)) return terminal; - const rest = segments.length > 1 ? segments.slice(1).filter((segment) => segment !== scope) : segments; - const word = (words: string[]): string | undefined => words.find((candidate) => /[A-Za-z]/.test(candidate)); - return ucfirst(word(nameWords(rest.join(' '))) ?? word(nameWords(id)) ?? terminal); - } - - private ofForm(form: IdForm, names?: string[]): IdFitter { - return new IdFitter({ suffixSep: form === 'stemTo' ? '' : '_', ...(names ? { names } : {}) }); - } - /** * Each end gives up words one by one, never its first or its last word with a letter, never a * word between two numbers (`Stufe23`); of the pairs that fit, most words, then most @@ -144,11 +62,6 @@ export class IdFitter { return long ? [other, cut] : [cut, other]; } - private wholePair(sourceEnd: string, targetEnd: string, room: number): [string, string] | undefined { - if (sourceEnd.length + targetEnd.length <= room) return [sourceEnd, targetEnd]; - return this.bestPair(sourceEnd, targetEnd, room).best; - } - /** The pair of end forms that scores best; later pairs win a full tie, as the source has given up more. */ private bestPair(sourceEnd: string, targetEnd: string, room: number): { best?: [string, string]; fa: EndForm[]; fb: EndForm[] } { const keepLast = beginAlike(sourceEnd, targetEnd); diff --git a/src/domain/document/ids/IdRegistry.ts b/src/domain/document/ids/IdRegistry.ts index 4eda425b..7ef4f648 100644 --- a/src/domain/document/ids/IdRegistry.ts +++ b/src/domain/document/ids/IdRegistry.ts @@ -25,7 +25,7 @@ export class IdRegistry { } /** - * Whether another id shares the body of `id` after its prefix (`Gateway_X` for `Activity_X`): + * Whether another id shares the body of `id` after its prefix (`gateway_x` for `task_x`): * a flow between them would name both alike. */ bodyTaken(id: string): boolean { diff --git a/src/domain/document/ids/IdStyle.ts b/src/domain/document/ids/IdStyle.ts index d83069ca..2bd6b6ec 100644 --- a/src/domain/document/ids/IdStyle.ts +++ b/src/domain/document/ids/IdStyle.ts @@ -1,73 +1,43 @@ -import { camelSlug, cutAt, cutName, hasIdWords, idWords, isFiller, isValidId, MAX_ID, MAX_SLUG, nameWords, pascalSnakeSlug, slugify, snakeSlug, withSuffix } from '../utils/slug.js'; +import { camelSlug, cutAt, cutName, hasIdWords, isValidId, kebabSlug, MAX_ID, MAX_SLUG, nameWords, slugify, snakeSlug, withSuffix } from '../utils/slug.js'; import type { IdRegistry } from './IdRegistry.js'; -import { FORM_JOIN, IdFitter } from './IdFitter.js'; -import { caseLike, CONTEXT_WORDS, kindWord, PREFIXED, speakingStem, stemWords } from '../utils/id-words.js'; -import { CONNECTIONS, connectionRequest, kindRequest, typeRequest, type IdRequest } from '../utils/requests.js'; -import type { IdConventions } from './IdConventions.js'; +import { IdFitter } from './IdFitter.js'; +import { caseLike, kindWord, lcfirst, localName, PREFIXED, speakingStem, stemWords } from '../utils/id-words.js'; +import { CONNECTIONS, kindRequest, typeRequest, type IdRequest } from '../utils/requests.js'; +import { DEFAULT_ID_CONVENTIONS, UNCHECKED_PREFIXES, type IdConventions } from './IdConventions.js'; import { DeclaredConventions } from './DeclaredConventions.js'; -import { DEFAULT_FLOW, LearnedStyle } from './LearnedStyle.js'; import { FLOW_NODE_FAMILIES, KINDS, kindByName } from '../Kinds.js'; -import type { El } from '../utils/moddle.js'; /** The case of the bodies of new ids. */ -export type Body = 'pascal' | 'camel' | 'snake' | 'pascalSnake'; -export type FlowForm = 'named' | 'stemTo' | 'idPair' | 'stemSnake' | 'idSnake' | 'scopedTo'; - -export interface FlowStyle { - form: FlowForm; - prefix: string; - sep?: string; - scope?: string; - /** a declared prefix with its separator (`flow_`), which replaces `_` */ - head?: string; -} - -export interface IdStyleInfo { - body: Body; - sequenceFlow: FlowStyle; - messageFlow: FlowStyle; -} +export type Body = 'camel' | 'pascal' | 'snake' | 'kebab'; -export type PrefixSource = 'lint' | 'file' | 'lint shape' | 'default'; +/** lint: declared by element-id-naming; default: the rule's defaults, nothing declared; bpmn-cli: a type the rule does not check */ +export type PrefixSource = 'lint' | 'default' | 'bpmn-cli'; export interface IdState { type: string; trigger?: string; } -/** - * Under declared conventions these keep bpmn-cli's own ids unless declared exactly: learning - * them from declared-prefix ids would drift (process_supplier, then process_back_office). - */ -const FILE_LEVEL = new Set(['bpmn:Process', 'bpmn:Definitions']); - -/** Their prefix says the kind, so an unnamed one's id leaves it out (Gateway_AfterCheck). */ +/** Their prefix says the kind, so an unnamed one's id leaves it out (gateway_afterCheck). */ const SPELLABLE = new Set(KINDS.filter((def) => FLOW_NODE_FAMILIES.has(def.family) && def.family !== 'event' && def.kind !== 'eventSubProcess').map((def) => def.kind)); +/** An unnamed process without a declared prefix keeps the tool default. */ +const NUMBERED_PROCESS = 'Process_'; + /** - * The id conventions of a document, so that new ids look like the ones it already has and every - * new id says what it names (never a hash or a running number). Declared conventions from the - * .bpmnlintrc fill the gaps; existing ids are never renamed for them. + * The id conventions new ids follow: the declared ones of element-id-naming, else the rule's + * defaults; types the rule does not check take bpmn-cli's own prefix. Every new id says what it + * names (never a hash or a running number); existing ids are never renamed for the conventions. */ export class IdStyle { private constructor( - private readonly learned: LearnedStyle, - private readonly declaredConventions?: DeclaredConventions, - /** with declared conventions `learned` leaves out the ids a declared prefix covers; this does not */ - private readonly all: LearnedStyle = learned, + private readonly conventions: DeclaredConventions, + /** false: nothing declared, the rule's defaults apply */ + readonly declared: boolean, ) {} - static readonly DEFAULT = new IdStyle(new LearnedStyle(undefined)); - - static infer(defs: El, conventions?: IdConventions): IdStyle { - const declared = DeclaredConventions.of(conventions); - if (!declared) return new IdStyle(new LearnedStyle(defs)); - return new IdStyle(new LearnedStyle(defs, declared), declared, new LearnedStyle(defs)); - } - static of(conventions?: IdConventions): IdStyle { - const declared = DeclaredConventions.of(conventions); - return declared ? new IdStyle(new LearnedStyle(undefined), declared) : IdStyle.DEFAULT; + return new IdStyle(DeclaredConventions.of(conventions ?? DEFAULT_ID_CONVENTIONS), conventions !== undefined); } static keyRequest(key: string): IdRequest { @@ -75,310 +45,178 @@ export class IdStyle { return def ? kindRequest(def) : typeRequest(key); } - get declared(): boolean { - return this.declaredConventions !== undefined; - } - - /** - * Only where the file has checked ids that do not follow the declared conventions is a new id - * under a declared prefix one the file's own style would give otherwise. - */ - get ownStyleDiffers(): boolean { - return this.learned.unconforming; - } - - private processMemo: { prefix: string; body: Body } | null | undefined; - - private processStyle(): { prefix: string; body: Body } | undefined { - if (this.processMemo === undefined) this.processMemo = this.learned.processStyle(() => this.caseFor()) ?? null; - return this.processMemo ?? undefined; - } - - private fileLevel(req: IdRequest): boolean { - return !!this.declaredConventions && FILE_LEVEL.has(req.key) && this.declaredHead(req) === undefined && !(req.key === 'bpmn:Process' && this.processStyle()); - } - - private own: IdStyle | undefined; - - learnedOnly(): IdStyle { - return this.declaredConventions ? (this.own ??= new IdStyle(this.all)) : this; - } - - info(): IdStyleInfo { - const sequenceFlow = this.flowOf('bpmn:SequenceFlow'); - return { body: this.caseFor(), sequenceFlow, messageFlow: this.flowOf('bpmn:MessageFlow') }; - } - - /** - * A declared prefix replaces the file's, with the `named` form under a declared case (the one - * form whose body passes it). Message flows borrow the sequence flows' until they show a style. - */ - private flowOf(type: string): FlowStyle { - const declared = this.declaredConventions; - if (!declared) return this.learned.flowOf(type); - const borrowed = this.learned.showsFlowStyle(type) ? undefined : declared.borrowedHead(type); - const learned = this.learned.flowOf(borrowed === undefined ? type : 'bpmn:SequenceFlow'); - const head = declared.prefixes[type] ?? borrowed; - if (head === undefined) return learned; - return { ...learned, form: declared.case ? 'named' : learned.form, head }; - } - - private declaredHead(req: IdRequest): string | undefined { - return this.declaredConventions?.headFor(this.typeOf(req.key), req.trigger); - } - - private declares(req: IdRequest): boolean { - if (!this.declaredConventions) return false; - return CONNECTIONS.has(req.key) ? this.flowOf(req.key).head !== undefined : this.declaredHead(req) !== undefined; - } - - private caseFor(req?: IdRequest): Body { - const own = req?.key === 'bpmn:Process' && !this.declares(req) ? this.processStyle() : undefined; - if (own) return own.body; - if (!this.declaredConventions) return this.learned.body; - if (req && this.declares(req)) return this.declaredConventions.case ?? this.all.body; - if (this.learned.bodyVoted) return this.learned.body; - if (!this.declaredConventions.case) return this.all.body; - return this.learned.otherShape(this.declaredConventions) ? this.learned.body : this.declaredConventions.case; + /** `any` takes camelCase. */ + private get body(): Body { + return this.conventions.bodyCase ?? 'camel'; } /** No `_` in the body and a letter first: a glued suffix, `Join`, a kind word first. */ - private lettersOnly(req: IdRequest): boolean { - return this.declares(req) && (this.declaredConventions!.case === 'camel' || this.declaredConventions!.case === 'pascal'); + private get lettersOnly(): boolean { + return this.body === 'camel' || this.body === 'pascal'; } - private sepFor(req: IdRequest): string { - return this.lettersOnly(req) ? '' : '_'; + suffixSep(): string { + if (this.lettersOnly) return ''; + return this.body === 'kebab' ? '-' : '_'; } - suffixSep(key: string): string { - return this.sepFor(IdStyle.keyRequest(key)); + /** '' for a named process without a declared prefix: its id is its name. */ + private headOf(req: IdRequest): { head: string; source: PrefixSource } { + const type = this.typeOf(req.key); + const declared = this.conventions.headFor(type, req.trigger); + if (declared !== undefined) return { head: declared, source: this.declared ? 'lint' : 'default' }; + if (type === 'bpmn:Process') return { head: '', source: 'bpmn-cli' }; + return { head: caseLike(UNCHECKED_PREFIXES[type] ?? `${lcfirst(localName(type))}_`, !this.conventions.upperFirst), source: 'bpmn-cli' }; } - /** '' only with `bare`: a named element's id can do without a prefix, an unnamed one cannot. */ - prefixFor(req: IdRequest, bare = true): string { - return this.learnedPrefix(req, bare).prefix; + private numbered(req: IdRequest): boolean { + return this.headOf(req).head === '' && !hasIdWords(req.name); } - private learnedPrefix(req: IdRequest, bare = true): { prefix: string; source: PrefixSource } { - const processes = req.key === 'bpmn:Process' ? this.processStyle() : undefined; - if (processes) return { prefix: processes.prefix, source: 'file' }; - return this.learned.prefixOf(req, bare, this.declaredConventions); - } - - private bodyOf(text: string | undefined, bodyCase: Body = this.learned.body): string { - switch (bodyCase) { + private bodyOf(text: string | undefined): string { + switch (this.body) { case 'camel': return camelSlug(text); case 'snake': return snakeSlug(text); - case 'pascalSnake': - return pascalSnakeSlug(text); + case 'kebab': + return kebabSlug(text); default: return slugify(text); } } - /** Never snake_case: it would read as a prefix. */ - private bareBody(name: string | undefined): string { - const body = this.learned.bareBody === 'pascal' ? slugify(name) : camelSlug(name); - return body && isValidId(body) && !PREFIXED.test(body) ? body : ''; - } - /** - * A kind word and the context (`Timer On Review`); `spelled` adds the kind word a gateway's or - * activity's prefix otherwise says (`Parallel After Check invoice`). + * A kind word and the context (`Timer On Review`), minus what the head says; `spelled` adds + * the kind word a gateway's or activity's prefix otherwise says (`Parallel After Check invoice`). */ - private unnamedText(req: IdRequest, spelled = false, head?: string): string { + private unnamedText(req: IdRequest, head: string, spelled = false): string { const context = nameWords(req.context).length ? req.context! : ''; const word = kindWord(req.key, req.trigger, !!context && !spelled); - return `${head === undefined ? word : this.unsaid(word, head)} ${context}`.trim(); + return `${this.unsaid(word, head)} ${context}`.trim(); } - /** - * `qualified`: the context ends with the anchor's kind, which a cut leaves in place. - * An unnamed element keeps a prefix in a file whose named ids have none. - */ + /** `qualified`: the context ends with the anchor's kind, which a cut leaves in place. */ private derived(req: IdRequest, spelled = false, qualified = false, max = MAX_ID): string { const named = hasIdWords(req.name); - const bodyCase = this.caseFor(req); - const head = this.declaredHead(req); - if (head !== undefined) { - const room = max - head.length; - const body = this.declaredBody(named ? req.name! : this.unnamedText(req, spelled, head), req, head, bodyCase); - if (qualified && !named && req.anchorKind) return `${head}${this.withWord(body, req.anchorKind, Math.min(room, MAX_SLUG), bodyCase)}`; - if (req.tailWord) return `${head}${this.withWord(body, req.tailWord, room, bodyCase)}`; - return `${head}${this.cutBody(body, room, bodyCase)}`; - } - const prefix = this.prefixFor(req); - if (prefix === '' && named) { - const bare = this.bareBody(req.name); - if (bare) return req.tailWord ? this.withWord(bare, req.tailWord, max, this.learned.bareBody) : this.cutBody(bare, max, this.learned.bareBody); - } - let chosenPrefix = prefix === '' ? this.prefixFor(req, false) : prefix; - const body = this.bodyOf(named ? req.name : this.unnamedText(req, spelled), bodyCase) || this.bodyOf(kindWord(req.key, req.trigger), bodyCase); - /** - * A prefix that leaves under 8 characters beside `_99` stays where the body's lead fits beside - * it whole, else the bpmn-cli prefix takes its place (activity_auftrag). - */ - if (MAX_ID - chosenPrefix.length - 1 - 3 < 8 && this.leadOf(body, !named).length > max - chosenPrefix.length - 1) chosenPrefix = caseLike(req.prefix, /^[a-z]/.test(chosenPrefix)); - const room = max - chosenPrefix.length - 1; - if (qualified && !named && req.anchorKind) return `${chosenPrefix}_${this.withWord(body, req.anchorKind, Math.min(room, MAX_SLUG), bodyCase)}`; - if (req.tailWord) return `${chosenPrefix}_${this.withWord(body, req.tailWord, room, bodyCase)}`; - return `${chosenPrefix}_${this.cutBody(body, room, bodyCase)}`; + const { head } = this.headOf(req); + const room = max - head.length; + const body = this.bodyFor(named ? req.name! : this.unnamedText(req, head, spelled), req, head); + if (qualified && !named && req.anchorKind) return `${head}${this.withWord(body, req.anchorKind, Math.min(room, MAX_SLUG))}`; + return `${head}${this.cutBody(body, room)}`; } /** - * A body that would begin with a number under camelCase / PascalCase begins with the kind word, - * minus what the prefix says (startEvent_timer1stOfMonth, scriptTask_scriptTask4Augen). + * A body that would begin with a number where a letter must come first begins with the kind + * word, minus what the head says (startEvent_timer1stOfMonth, scriptTask_scriptTask4Augen). */ - private declaredBody(text: string, req: IdRequest, head: string, bodyCase: Body): string { - const body = this.bodyOf(text, bodyCase); - if (body && !(this.lettersOnly(req) && /^[0-9]/.test(body))) return body; - const word = kindWord(req.key, req.trigger); - return this.bodyOf(`${this.unsaid(word, head) || word} ${text}`, bodyCase); + private bodyFor(text: string, req: IdRequest, head: string): string { + const body = this.bodyOf(text); + if (body && !((this.lettersOnly || head === '') && /^[0-9]/.test(body))) return body; + const word = head === '' ? 'Process' : kindWord(req.key, req.trigger); + return this.bodyOf(`${this.unsaid(word, head) || word} ${text}`); } /** The body is cut so the word stays; under 8 characters left, both are cut as one. */ - private withWord(body: string, word: string, max: number, bodyCase: Body): string { - const tail = bodyCase === 'snake' ? `_${snakeSlug(word)}` : bodyCase === 'pascalSnake' ? `_${pascalSnakeSlug(word)}` : slugify(word); + private withWord(body: string, word: string, max: number): string { + const tail = this.body === 'snake' ? `_${snakeSlug(word)}` : this.body === 'kebab' ? `-${kebabSlug(word)}` : slugify(word); return max - tail.length >= 8 ? `${cutName(body, max - tail.length)}${tail}` : cutName(`${body}${tail}`, max); } - /** A taken unnamed gateway or activity spells its kind (Gateway_ParallelAfterCheck). */ + /** A taken unnamed gateway or activity spells its kind (gateway_parallelAfterCheck). */ private spelledBase(req: IdRequest): string | undefined { if (!SPELLABLE.has(req.key) || hasIdWords(req.name) || !nameWords(req.context).length) return undefined; const word = kindWord(req.key, req.trigger).toLowerCase(); - const prefix = (this.declaredHead(req) ?? (this.prefixFor(req) || this.prefixFor(req, false))).toLowerCase(); - if (prefix.includes(word)) return undefined; + if (this.headOf(req).head.toLowerCase().includes(word)) return undefined; return this.derived(req, true); } - /** An id that repeats its unnamed anchor's names the anchor's kind (Task_AfterCheckGateway). */ + /** An id that repeats its unnamed anchor's names the anchor's kind (task_afterCheckGateway). */ private qualifiedBase(req: IdRequest): string | undefined { if (!req.anchorKind || hasIdWords(req.name) || !nameWords(req.context).length) return undefined; return this.derived(req, false, true); } derivedBase(req: IdRequest): string { - return this.wanted(req).base; + return this.wanted(req); } /** - * Every id the request may have been given before (uncut, cut for a suffix, in every other - * flow style the file shows), so a flow named earlier still names its ends. - * `id` limits this to the styles it can belong to. + * Every id the request may have been given before (uncut, and cut for a suffix), so a flow + * named earlier still names its ends. */ - derivedBases(req: IdRequest, id?: string): string[] { - const own = this.basesOf(req, id); - return this.declaredConventions ? [...new Set([...own, ...this.learnedOnly().basesOf(req, id)])] : own; - } - - private basesOf(req: IdRequest, id?: string): string[] { - if (!this.isConnection(req)) { - const uncut = this.wanted(req, Infinity).base; - return uncut.length <= MAX_ID - 4 ? [uncut] : [...new Set([...[0, 1, 2, 3, 4].map((suffix) => this.wanted(req, MAX_ID - suffix).base), uncut])]; - } - const style = this.flowOf(req.key); - const bases = this.connectionBases(req, style); - if (style.head === undefined) for (const other of this.learned.otherFlowStyles(req.key)) if (id === undefined || this.mayBeOf(id, other)) bases.push(...this.connectionBases(req, other)); - return [...new Set(bases)]; - } - - private connectionBases(req: IdRequest, style: FlowStyle): string[] { - const at = (max: number): string => this.connectionBase(req, max, style).base; - const uncut = at(Infinity); - return [...(uncut.length <= MAX_ID - 4 ? [] : [0, 1, 2, 3, 4].map((suffix) => at(MAX_ID - suffix))), uncut]; + derivedBases(req: IdRequest): string[] { + const uncut = this.wanted(req, Infinity); + return uncut.length <= MAX_ID - 4 ? [uncut] : [...new Set([...[0, 1, 2, 3, 4].map((suffix) => this.wanted(req, MAX_ID - suffix)), uncut])]; } - private wanted(req: IdRequest, max = MAX_ID): { base: string; sep: string } { + private wanted(req: IdRequest, max = MAX_ID): string { if (this.isConnection(req)) return this.connectionBase(req, max); - if (this.fileLevel(req)) return IdStyle.DEFAULT.wanted(req, max); - return { base: this.derived(req, false, false, max), sep: this.sepFor(req) }; + if (this.numbered(req)) return `${NUMBERED_PROCESS}1`; + return this.derived(req, false, false, max); } explain(req: IdRequest): { head: string; source: PrefixSource; body: Body } { - const head = this.declaredHead(req); - if (head !== undefined) return { head, source: 'lint', body: this.caseFor(req) }; - if (this.fileLevel(req)) return IdStyle.DEFAULT.explain(req); - const { prefix, source } = this.learnedPrefix(req); - return { head: prefix ? `${prefix}_` : '', source, body: this.caseFor(req) }; - } - - /** `fallback`: Flow_ where a long head leaves the ends too little room. */ - explainFlow(type: 'bpmn:SequenceFlow' | 'bpmn:MessageFlow'): { style: FlowStyle; head: string; form: FlowForm; fallback?: string; source: PrefixSource; body: Body } { - const style = this.flowOf(type); - const source: PrefixSource = style.head !== undefined ? 'lint' : this.learned.hasFlows(type) ? 'file' : 'default'; - const body = this.caseFor(connectionRequest(type, '', '')); - return { style, ...this.effectiveHead(style, body), source, body }; + return { ...this.headOf(req), body: this.body }; } /** - * W_ID_PREFIX_STALE: a retype or trigger change kept an id whose prefix the declared conventions - * no longer accept. Suggests the new prefix with the rest of the id where that passed the rule, - * else the id `now` wants (never the old kind's words). Ids are never renamed for this. An id - * that starts with the prefix by accident (userTask_checkOrder under `user`, Task_ completing the - * type name) is suggested from the words after the type name. + * W_ID_PREFIX_STALE: a retype or trigger change kept an id whose prefix the conventions no + * longer accept. Suggests the new prefix with the rest of the id where that passed the rule, + * else the id `now` wants (never the old kind's words). An id that starts with the prefix by + * accident (userTask_checkOrder under `user`, Task_ completing the type name) is suggested + * from the words after the type name. */ stalePrefix(id: string, from: IdState, to: IdState, now?: IdRequest): { was?: string; heads: string[]; suggested: string } | undefined { - const declared = this.declaredConventions; - if (!declared) return undefined; - const heads = declared.headsOf(to); - if (!heads.length || declared.acceptsId(id, to)) return undefined; - const was = declared.headOf(id, from); - if (was === undefined && declared.headsOf(from).length) return undefined; + const conventions = this.conventions; + const heads = conventions.headsOf(to); + if (!heads.length || conventions.acceptsId(id, to)) return undefined; + const was = conventions.headOf(id, from); + if (was === undefined && conventions.headsOf(from).length) return undefined; const rest = was === undefined ? undefined : id.slice(was.length); const own = rest !== undefined && /[A-Za-z0-9]$/.test(was!) ? PREFIXED.exec(rest) : null; const accidental = own && `${was}${own[1]}`.toLowerCase() === from.type.slice(from.type.indexOf(':') + 1).toLowerCase() ? own : null; - if (now && (rest === undefined || accidental || !declared.inDeclaredCase(id, from))) { + if (now && (rest === undefined || accidental || !conventions.inDeclaredCase(id, from))) { const words = rest === undefined ? (speakingStem(id) ?? '') : accidental ? accidental[2]! : rest; const suggested = this.derivedBase(hasIdWords(now.name) ? now : { ...now, context: stemWords(words).join(' ') }); return isValidId(suggested) ? { ...(was !== undefined ? { was } : {}), heads, suggested } : undefined; } if (was === undefined) return undefined; - const head = declared.headFor(to.type, to.trigger)!; - const suggested = `${head}${id.slice(was.length)}`; + const suggested = `${conventions.headFor(to.type, to.trigger)!}${id.slice(was.length)}`; return isValidId(suggested) ? { was, heads, suggested } : undefined; } /** - * The id of a split's join gateway (`_join`, `Join`). A split id outside the declared - * conventions gives the declared prefix with its label (Gateway_Check -> gateway_checkJoin). + * The id of a split's join gateway (`Join`, `_join`, `-join`). A split id outside + * the conventions gives the prefix with its label (Gateway_Check -> gateway_checkJoin). */ joinId(gatewayId: string, key = 'exclusiveGateway', label = speakingStem(gatewayId) ?? gatewayId): string { const req = IdStyle.keyRequest(key); - const head = this.declaredHead(req); + const { head } = this.headOf(req); const state = { type: this.typeOf(key) }; - if (head !== undefined && !this.declaredConventions!.inDeclaredCase(gatewayId, state)) { - const bodyCase = this.caseFor(req); - const own = this.declaredConventions!.headOf(gatewayId, state); + if (!this.conventions.inDeclaredCase(gatewayId, state)) { + const own = this.conventions.headOf(gatewayId, state); const body = own === undefined ? '' : stemWords(gatewayId.slice(own.length)).join(' '); - return `${head}${this.withWord(this.declaredBody(hasIdWords(body) ? body : label, req, head, bodyCase), 'Join', MAX_ID - head.length, bodyCase)}`; + return `${head}${this.withWord(this.bodyFor(hasIdWords(body) ? body : label, req, head), 'Join', MAX_ID - head.length)}`; } const base = cutAt(gatewayId, MAX_ID - '_join'.length); - if (head !== undefined && this.declaredConventions!.case) return this.declaredConventions!.case === 'snake' ? `${base}_join` : `${base}Join`; - if (!gatewayId.includes('_') && this.learned.hasBareNodes) return `${base}Join`; - switch (this.caseFor(req)) { - case 'camel': - return `${base}Join`; - case 'pascalSnake': - return `${base}_Join`; - default: - return `${base}_join`; - } + if (this.body === 'snake') return `${base}_join`; + return this.body === 'kebab' ? `${base}-join` : `${base}Join`; } /** * A free id (not claimed) and the id it wanted. A taken unnamed gateway or activity first * spells its kind, then names its anchor's kind, before a collision suffix; the same request * on the same document always gives the same id. When the base is taken only by its body, the - * form that names the anchor is suffixed; when a long head gives way to Flow_ in the cut for the - * suffix, the whole id takes that form. + * form that names the anchor is suffixed. An unnamed process takes the first free Process_. */ next(req: IdRequest, ids: IdRegistry): { id: string; base: string } { - const wanted = this.wanted(req); - const { sep } = wanted; - let { base } = wanted; + if (!this.isConnection(req) && this.numbered(req)) { + let number = 1; + while (ids.has(`${NUMBERED_PROCESS}${number}`)) number++; + return { id: `${NUMBERED_PROCESS}${number}`, base: `${NUMBERED_PROCESS}${number}` }; + } + let base = this.wanted(req); const qualified = CONNECTIONS.has(req.key) ? undefined : this.qualifiedBase(req); const others = CONNECTIONS.has(req.key) ? [] : [this.spelledBase(req), qualified].filter((candidate): candidate is string => !!candidate && candidate !== base); const clash = (id: string): boolean => ids.has(id) || ids.bodyTaken(id); @@ -387,109 +225,28 @@ export class IdStyle { if (pick) return { id: pick, base: pick }; if (qualified && !ids.has(base)) base = qualified; } + const recut = (max: number): string => (base === qualified ? this.derived(req, false, true, max) : this.wanted(req, max)); let id = base; - const recut = (max: number): { base: string; sep: string } => (base === qualified ? { base: this.derived(req, false, true, max), sep } : this.wanted(req, max)); - const suffixed = (suffixNumber: number): string => { - let given: { base: string; sep: string } | undefined; - const out = withSuffix(base, sep, suffixNumber, (max) => { - const recutBase = recut(max); - if (recutBase.sep !== sep) given ??= recutBase; - return recutBase.base; - }); - if (!given) return out; - const suffix = `${given.sep}${suffixNumber}`; - const room = recut(MAX_ID - suffix.length); - return `${room.sep === given.sep ? room.base : given.base}${suffix}`; - }; - for (let suffixNumber = 2; ids.has(id); suffixNumber++) id = suffixed(suffixNumber); + for (let suffixNumber = 2; ids.has(id); suffixNumber++) id = withSuffix(base, this.suffixSep(), suffixNumber, recut); return { id, base }; } - /** - * A head that leaves the ends under 16 characters stays per flow where they fit beside it at - * whole words, else gives way to Flow_, so one file can hold both forms. - */ - private connectionBase(req: IdRequest, max: number, style = this.flowOf(req.key)): { base: string; sep: string } { - const { form, prefix, sep, scope, head } = style; - if (head !== undefined) return this.declaredFlowBase(req, max, form, head); - if (form === 'named') return { base: this.namedBase(req, max, prefix, sep), sep: '_' }; - const long = (head: string, join: string): boolean => MAX_ID - head.length - join.length < 16; - if (form === 'scopedTo') { - const sourceId = req.source!; - const targetId = req.target!; - const own = this.scopeOf(req, scope!); - const head = `${prefix}_${own}_`; - const fitter = new IdFitter({ suffixSep: '' }); - const [sourceWord, targetWord] = [fitter.endWord(sourceId, own, 'Start'), fitter.endWord(targetId, own, 'End')]; - const base = long(head, 'To') ? fitter.whole(head, sourceWord, 'To', targetWord, max) : fitter.pair(head, sourceWord, 'To', targetWord, max); - return base !== undefined ? { base, sep: '' } : { base: this.namedBase({ ...req, sourceLabel: sourceWord, targetLabel: targetWord }, max, DEFAULT_FLOW.prefix, undefined), sep: '_' }; - } - const own = form === 'stemTo' ? '' : '_'; - const fitter = new IdFitter(); - if (!long(`${prefix}_`, FORM_JOIN[form])) return { base: fitter.idForm(form, `${prefix}_`, req, max), sep: own }; - const base = fitter.wholeIdForm(form, `${prefix}_`, req, max); - return base !== undefined ? { base, sep: own } : { base: this.namedBase(req, max, DEFAULT_FLOW.prefix, undefined), sep: '_' }; - } - - /** The scope an end names in its id (KotO in serviceTask_KotO_Validate), else the most used. */ - private scopeOf(req: IdRequest, scope: string): string { - const scopes = this.learned.scopesOf(req.key) ?? [scope]; - return scopes.find((candidateScope) => req.source!.split('_').includes(candidateScope) || req.target!.split('_').includes(candidateScope)) ?? scope; - } - - /** - * A declared head never gives way to Flow_. The ends leave out the file's scope segment, and - * one that begins with a number is written `From...` where the case wants a letter first. - */ - private declaredFlowBase(req: IdRequest, max: number, form: FlowForm, head: string): { base: string; sep: string } { - const sep = this.sepFor(req); - if (form === 'named') { - const bodyCase = this.caseFor(req); - const scopes = this.learned.scopesOf(req.key) ?? []; - const label = (id: string, given: string | undefined): string => { - const text = given && nameWords(given).length ? given : (speakingStem(id) ?? id); - return (scopes.length && text.split('_').filter((segment) => !scopes.includes(segment)).join('_')) || text; - }; - const source = label(req.source!, req.sourceLabel); - const target = label(req.target!, req.targetLabel); - const fitter = new IdFitter({ body: bodyCase, suffixSep: sep, ...(bodyCase === 'camel' ? { names: nameWords(source) } : {}) }); - let [sourceEnd, join, targetEnd] = fitter.ends(source, target, false); - if (this.lettersOnly(req) && /^[0-9]/.test(sourceEnd)) sourceEnd = fitter.ends(`From ${source}`, target, false)[0]; - return { base: fitter.pair(head, sourceEnd, join, targetEnd, max), sep }; - } - if (form === 'scopedTo') { - const own = this.scopeOf(req, this.flowOf(req.key).scope!); - const fitter = new IdFitter({ suffixSep: sep }); - const [sourceWord, targetWord] = [fitter.endWord(req.source!, own, 'Start'), fitter.endWord(req.target!, own, 'End')]; - return { base: fitter.pair(`${head}${own}_`, sourceWord, 'To', targetWord, max), sep }; - } - return { base: new IdFitter().idForm(form, head, req, max), sep }; - } - - private namedBase(req: IdRequest, max: number, prefix: string, sep: string | undefined): string { + /** `To`; an end that begins with a number is written `From...` where the case wants a letter first. */ + private connectionBase(req: IdRequest, max: number): string { + const { head } = this.headOf(req); const label = (id: string, given: string | undefined): string => (given && nameWords(given).length ? given : (speakingStem(id) ?? id)); - const bodyCase = this.caseFor(req); - const { head, glued, long } = new IdFitter({ body: bodyCase }).namedHead(prefix, sep); const source = label(req.source!, req.sourceLabel); - const fitter = new IdFitter({ body: bodyCase, ...(bodyCase === 'camel' && !glued ? { names: nameWords(source) } : {}) }); - const [sourceEnd, join, targetEnd] = fitter.ends(source, label(req.target!, req.targetLabel), glued); - if (!long) return fitter.pair(head, sourceEnd, join, targetEnd, max); - return fitter.whole(head, sourceEnd, join, targetEnd, max) ?? this.namedBase(req, max, DEFAULT_FLOW.prefix, undefined); + const target = label(req.target!, req.targetLabel); + const fitter = new IdFitter({ body: this.body, suffixSep: this.suffixSep(), ...(this.body === 'camel' ? { names: nameWords(source) } : {}) }); + const [sourceEnd, join, targetEnd] = fitter.ends(source, target); + const from = this.lettersOnly && /^[0-9]/.test(sourceEnd) ? (this.body === 'pascal' ? 'From' : 'from') : ''; + return fitter.pair(`${head}${from}`, sourceEnd, join, targetEnd, max); } private isConnection(req: IdRequest): boolean { return CONNECTIONS.has(req.key) && req.source !== undefined && req.target !== undefined; } - /** - * A cheap filter: a cut never reaches past the head's first 32 characters, and a head from - * 40 characters may have given way to Flow_. - */ - private mayBeOf(id: string, style: FlowStyle): boolean { - const head = style.form === 'scopedTo' && style.scope !== undefined ? `${style.prefix}_${style.scope}_` : style.prefix; - return id.startsWith(head.slice(0, 32)) || (style.prefix.length + (style.scope?.length ?? 0) >= 40 && id.startsWith(`${DEFAULT_FLOW.prefix}_`)); - } - private typeOf(key: string): string { return kindByName(key)?.type ?? key; } @@ -502,35 +259,9 @@ export class IdStyle { .join(''); } - private effectiveHead(style: FlowStyle, bodyCase: Body): { head: string; form: FlowForm; fallback?: string } { - const { form, prefix, sep, scope, head } = style; - if (head !== undefined) return { head, form }; - const fitter = new IdFitter({ body: bodyCase }); - const fallback = fitter.namedHead(DEFAULT_FLOW.prefix, undefined).head; - if (form === 'named') { - const own = fitter.namedHead(prefix, sep); - if (!own.long) return { head: own.head, form }; - return MAX_ID - own.head.length - (bodyCase === 'snake' || bodyCase === 'pascalSnake' ? 4 : 2) < 2 ? { head: fallback, form } : { head: own.head, form, fallback }; - } - const room = form === 'scopedTo' ? MAX_ID - `${prefix}_${scope}_`.length - 'To'.length : MAX_ID - prefix.length - 1 - FORM_JOIN[form].length; - if (room >= 16) return { head: `${prefix}_`, form }; - return room < 2 ? { head: fallback, form: 'named' } : { head: `${prefix}_`, form, fallback }; - } - /** A camelCase body that lost its leading number to the cut begins in lower case (`rechnung`). */ - private cutBody(body: string, max: number, bodyCase: Body): string { + private cutBody(body: string, max: number): string { const cut = cutName(body, max); - return bodyCase === 'camel' && /^[0-9]/.test(body) && /^[A-Z]/.test(cut) ? cut.replace(/^[A-Z]+(?![a-z])|^[A-Z]/, (capitals) => capitals.toLowerCase()) : cut; - } - - /** - * The words up to the first with a letter that is no filler (`10202422024Rechnung`); an unnamed - * element's after its context word, so its id still names the anchor (`afterCheck`). - */ - private leadOf(body: string, unnamed = false): string { - const words = idWords(body); - const from = unnamed ? words.slice(0, 3).findIndex((word) => CONTEXT_WORDS.has(word.replace(/^[_.-]+/, '').toLowerCase())) + 1 : 0; - const at = words.findIndex((word, index) => index >= from && /[A-Za-z]/.test(word) && !isFiller(word)); - return (at === -1 ? words : words.slice(0, at + 1)).join(''); + return this.body === 'camel' && /^[0-9]/.test(body) && /^[A-Z]/.test(cut) ? cut.replace(/^[A-Z]+(?![a-z])|^[A-Z]/, (capitals) => capitals.toLowerCase()) : cut; } } diff --git a/src/domain/document/ids/LearnedStyle.ts b/src/domain/document/ids/LearnedStyle.ts deleted file mode 100644 index dfeb6f38..00000000 --- a/src/domain/document/ids/LearnedStyle.ts +++ /dev/null @@ -1,538 +0,0 @@ -import { kindOf, triggerOf } from '../Kinds.js'; -import { is, isBpmnElement, walk, type El } from '../utils/moddle.js'; -import { isSuffix, MAX_ID, nameWords, pascalSnakeSlug, snakeSlug, SUFFIX_WORD } from '../utils/slug.js'; -import { beginAlike, caseLike, GENERIC, GLUED, HASH, lcfirst, localName, PREFIXED, prefixClass, stem, TOOL_DEFAULTS, ucfirst, type PrefixClass } from '../utils/id-words.js'; -import { CONNECTIONS, connectionRequest, kindTypeNames, type IdRequest } from '../utils/requests.js'; -import { ElementLabels } from './ElementLabels.js'; -import { ID_FORMS, IdFitter, FORM_JOIN, type IdForm } from './IdFitter.js'; -import { MIN_EVIDENCE, Tally } from './Tally.js'; -import type { DeclaredConventions, Votes } from './DeclaredConventions.js'; -import type { Body, FlowForm, FlowStyle, PrefixSource } from './IdStyle.js'; - -export const DEFAULT_FLOW: FlowStyle = { form: 'named', prefix: 'Flow' }; - -const NO_VOTES: Votes = { kind: 0, literal: 0, lower: 0, upper: 0 }; - -/** Flow_KotO_ValidateToReserve */ -const SCOPED_TO = /^([A-Za-z][A-Za-z0-9]*)_([A-Z][A-Za-z0-9]*?)To([A-Z][A-Za-z0-9]*)$/; -/** `flowCheckOrderToShip_2` in a file of flow12 ids: not a prefix and a number. */ -const GLUED_SUFFIXED = /^[A-Za-z][A-Za-z0-9]*To[A-Z0-9][A-Za-z0-9]*_\d+$/; -/** `flowCheckOrderToShip`, `fPackToWeigh`; a cut one may end on `To`. */ -const GLUED_NAMED = /^[A-Za-z][A-Za-z0-9]*To(?![a-z])[A-Za-z0-9]*$/; -/** May be a glued flow in a snake_case / Pascal_Snake body (`fweigh_parcel_to_label_parcel`). */ -const GLUED_JOINED = /^[a-z]\w*_[tT]o_\w*$/; - -type BodyClass = Body | 'hash' | 'numbered' | 'lower' | 'other'; - -type SeenForm = FlowForm | 'hash' | 'numbered'; -interface SeenFlow { - form: SeenForm; - prefix: string; - sep?: string; - scope?: string; -} - -/** Tie order: a file of half speaking flows gets speaking ones. */ -const FORM_ORDER: SeenForm[] = ['scopedTo', 'stemTo', 'idPair', 'stemSnake', 'idSnake', 'named', 'numbered', 'hash']; - -interface FamilyStats { - kindish: Tally; - generic: Tally; - prefixed: number; -} - -interface FlowVotes { - /** all but the glued ones with a suffix, which count only for glued numbered flows */ - count: number; - glued: string[]; - gluedNamed: string[]; - /** decided once the glued prefixes are known */ - joined: Array<{ el: El; id: string; source: string; target: string }>; - forms: Tally; - prefixes: Map>; - any: Tally; - /** breaks a tie between a form's prefixes */ - uses: Tally; - seps: Map; - scopes: Tally; -} - -interface IdsRead { - samples: number; - prefixed: number; - nodeBodies: Tally; - bareBodies: Tally; - otherBodies: Tally; - flowVotes: Map; -} - -interface Sample { - key: string; - family: string; - typeNames: string[]; - trigger?: string; - id: string; - named: boolean; - spellsName: boolean; - flowNode: boolean; -} - -const FLOW_NODE_FAMILIES = new Set(['task', 'subProcess', 'callActivity', 'gateway', 'event']); - -/** Ids nobody chooses say nothing about the conventions. */ -const IGNORED = ['bpmn:Definitions', 'bpmn:Process', 'bpmn:EventDefinition', 'bpmn:Expression', 'bpmn:ExtensionElements', 'bpmn:Documentation', 'bpmn:LoopCharacteristics', 'bpmn:InputOutputSpecification', 'bpmn:InputSet', 'bpmn:OutputSet']; - -/** - * The id conventions a document's ids show, learned by votes. With declared conventions the ids - * that already follow them do not vote. - */ -export class LearnedStyle { - private readonly byKey = new Map>(); - private readonly byKeyTrigger = new Map>(); - private readonly classByKey = new Map>(); - private readonly family = new Map(); - private readonly classes = new Tally(); - private lowerFirst = 0; - private upperFirst = 0; - readonly body: Body; - readonly bodyVoted: boolean; - private readonly flows = new Map(); - private readonly flowVoted = new Set(); - /** one id is evidence for its kind and family (MIN_EVIDENCE) */ - private readonly strong: boolean; - private readonly scopes = new Map(); - /** the styles besides the one new flows take, so flows named in them are still recognised */ - private readonly flowStyles = new Map(); - private bareNodes = 0; - private prefixedNodes = 0; - readonly bareBody: 'pascal' | 'camel'; - /** process ids vote on nothing but new process ids */ - private readonly processes = new Tally(); - private readonly processBodies = new Map>(); - private rejected = false; - - constructor(defs: El | undefined, declared?: DeclaredConventions) { - const read: IdsRead = { samples: 0, prefixed: 0, nodeBodies: new Tally(), bareBodies: new Tally(), otherBodies: new Tally(), flowVotes: new Map() }; - for (const el of defs ? walk(defs, { bpmnOnly: true }) : []) { - if (!isBpmnElement(el) || /^(bpmndi|dc|di):/.test(el.$type)) continue; - const id = el.get('id'); - if (!id) continue; - if (CONNECTIONS.has(el.$type)) this.readFlow(el, id, read.flowVotes, declared); - else this.readElement(el, id, read, declared); - } - - this.strong = read.prefixed >= 3 && read.prefixed * 2 >= read.samples; - - /** - * The named flow nodes decide the case (pools, lanes, messages often keep tool ids); - * a single lower-case word fits camel and snake alike. - */ - const { bareBodies, nodeBodies, otherBodies } = read; - this.bareBody = bareBodies.get('pascal') > bareBodies.get('camel') + bareBodies.get('lower') ? 'pascal' : 'camel'; - const bodies = nodeBodies.total >= MIN_EVIDENCE ? nodeBodies : otherBodies; - if (bodies === otherBodies) for (const [key, count] of nodeBodies.counts) bodies.add(key, count); - const camel = bodies.get('camel'); - const snake = bodies.get('snake'); - const lowerOnly = camel + snake === 0; - const lower = declared && lowerOnly && declared.case !== 'pascal' ? 0 : bodies.get('lower'); - const lowerCamel = (camel > 0 && camel >= snake) || (lowerOnly && declared?.case === 'pascal'); - const votes: Array<[Body, number]> = [ - ['pascal', bodies.get('pascal')], - ['camel', camel + (lowerCamel ? lower : 0)], - ['snake', snake + (lowerCamel ? 0 : lower)], - ['pascalSnake', bodies.get('pascalSnake')], - ]; - let best: [Body, number] = ['pascal', MIN_EVIDENCE - 1]; - for (const vote of votes) if (vote[1] > best[1]) best = vote; - this.body = best[0]; - this.bodyVoted = best[1] >= MIN_EVIDENCE; - - for (const [type, votes] of read.flowVotes) this.voteFlows(type, votes); - } - - /** The file has an id the declared conventions check that does not follow them. */ - get unconforming(): boolean { - return this.rejected; - } - - get hasBareNodes(): boolean { - return this.bareNodes > 0; - } - - flowOf(type: string): FlowStyle { - return this.flows.get(type) ?? this.flows.get('bpmn:SequenceFlow') ?? DEFAULT_FLOW; - } - - showsFlowStyle(type: string): boolean { - return this.flowVoted.has(type); - } - - hasFlows(type: string): boolean { - return this.flows.has(type) || this.flows.has('bpmn:SequenceFlow'); - } - - otherFlowStyles(type: string): FlowStyle[] { - return this.flowStyles.get(this.flows.has(type) ? type : 'bpmn:SequenceFlow') ?? []; - } - - scopesOf(type: string): string[] | undefined { - return this.scopes.get(type) ?? this.scopes.get('bpmn:SequenceFlow'); - } - - /** - * The prefix at least two process ids share and the case their bodies show, else the file's - * case, so the process matches the pool made with it. Participant ids never vote. - */ - processStyle(fileCase: () => Body): { prefix: string; body: Body } | undefined { - const prefix = this.processes.top(MIN_EVIDENCE, (first, second) => this.alphabetical(first, second)); - if (prefix === undefined) return undefined; - const caseTally = this.processBodies.get(prefix)!; - const file = fileCase(); - let best: [Body, number] = [file, 0]; - for (const bodyCase of ['pascal', 'camel', 'snake', 'pascalSnake'] as const) if (caseTally.get(bodyCase) > best[1] || (caseTally.get(bodyCase) === best[1] && best[1] > 0 && bodyCase === file)) best = [bodyCase, caseTally.get(bodyCase)]; - const body = best[1] || !caseTally.get('lower') ? best[0] : file === 'snake' || file === 'pascalSnake' ? 'snake' : 'camel'; - return { prefix, body }; - } - - /** - * '' only with `bare`: a named element's id can do without a prefix, an unnamed one cannot. - * The declared prefixes shape a prefix nothing declares where the file shows none, so a new - * file looks like one that follows the declared conventions. - */ - prefixOf(req: IdRequest, bare: boolean, declared: DeclaredConventions | undefined): { prefix: string; source: PrefixSource } { - const dv = declared && this.lowerFirst + this.upperFirst < MIN_EVIDENCE ? declared.votes : NO_VOTES; - const lowerFile = this.lowerFirst + dv.lower >= MIN_EVIDENCE && this.lowerFirst + dv.lower > this.upperFirst + dv.upper; - const min = this.strong ? 1 : MIN_EVIDENCE; - const typeName = req.typeNames[0]!; - const withTrigger = (lowerFirst: boolean): string => caseLike(req.trigger ? `${req.trigger}${ucfirst(typeName)}` : typeName, lowerFirst); - const usable = (prefix: string | undefined): prefix is string => prefix !== undefined && (bare || prefix !== ''); - const file = (prefix: string): { prefix: string; source: PrefixSource } => ({ prefix, source: 'file' }); - if (req.trigger) { - const exact = this.byKeyTrigger.get(`${req.key}|${req.trigger}`)?.top(min); - if (usable(exact)) return file(exact); - } - const own = this.byKey.get(req.key); - const ownTop = own?.top(min); - if (usable(ownTop)) { - const cls = this.classByKey.get(req.key)!; - if (ownTop && cls.get('triggerKind') > cls.get('kind') && cls.get('triggerKind') >= cls.get('literal')) return file(withTrigger(/^[a-z]/.test(ownTop))); - return file(ownTop); - } - const fam = this.family.get(req.prefix); - if (fam) { - const generic = fam.generic.top(min); - const kindish = fam.kindish.total; - if (usable(generic) && fam.generic.get(generic) >= kindish) return file(generic); - if (kindish >= MIN_EVIDENCE) return file(fam.kindish.get('triggerKind') > fam.kindish.get('kind') ? withTrigger(lowerFile) : caseLike(typeName, lowerFile)); - } - if (bare && !fam?.prefixed && this.bareNodes >= MIN_EVIDENCE && this.bareNodes > this.prefixedNodes) return file(''); - const kindish = this.classes.get('kind') + this.classes.get('triggerKind') + dv.kind; - const source: PrefixSource = dv.kind + dv.literal > 0 ? 'lint shape' : this.classes.total + this.lowerFirst + this.upperFirst > 0 ? 'file' : 'default'; - if (kindish >= MIN_EVIDENCE && kindish > this.classes.get('literal') + dv.literal) return { prefix: caseLike(typeName, lowerFile), source }; - return { prefix: caseLike(req.prefix, lowerFile), source }; - } - - /** - * The other ids show the other first letter than the declared prefixes (Lane_ next to - * serviceTask_): the declared case goes with the declared shape, so the file keeps bpmn-cli's. - */ - otherShape(declared: DeclaredConventions): boolean { - if (this.lowerFirst + this.upperFirst < MIN_EVIDENCE) return false; - const votes = declared.votes; - return this.lowerFirst > this.upperFirst ? votes.upper > votes.lower : this.upperFirst > this.lowerFirst && votes.lower > votes.upper; - } - - /** - * Collects a flow's vote. A message flow with the prefix bpmn-cli borrowed from the declared - * conventions does not vote, as that would end the borrowing. Glued snake ids are held back - * (fweigh_parcel_to_label_parcel reads like the prefix fweigh until the glued prefixes are known). - */ - private readFlow(el: El, id: string, flowVotes: Map, declared: DeclaredConventions | undefined): void { - if (declared && !this.rejected && declared.rejects({ type: el.$type }, id)) this.rejected = true; - const source = el.get('sourceRef')?.get('id'); - const target = el.get('targetRef')?.get('id'); - const borrowed = declared && el.$type === 'bpmn:MessageFlow' ? declared.borrowedHead(el.$type) : undefined; - if (borrowed !== undefined && id.startsWith(borrowed)) return; - let votes = flowVotes.get(el.$type); - if (!votes) flowVotes.set(el.$type, (votes = { count: 0, glued: [], gluedNamed: [], joined: [], forms: new Tally(), prefixes: new Map(), any: new Tally(), uses: new Tally(), seps: new Map(), scopes: new Tally() })); - if (GLUED_SUFFIXED.test(id)) { - votes.glued.push(id); - return; - } - votes.count++; - if (!id.includes('_') && GLUED_NAMED.test(id) && !GLUED.test(id)) { - votes.gluedNamed.push(id); - return; - } - if (source && target && GLUED_JOINED.test(id)) { - votes.joined.push({ el, id, source, target }); - return; - } - this.countFlow(votes, el, id, source, target); - } - - /** - * Counts an element's id for the learned style. A bare id counts only where it spells the name - * (`GW` or `Start` for "Started" say nothing); a generic prefix naming the type (`Task_` on a - * plain task) is neither for nor against type-named prefixes. - */ - private readElement(el: El, id: string, read: IdsRead, declared: DeclaredConventions | undefined): void { - if (TOOL_DEFAULTS.has(id)) return; - if (el.$type === 'bpmn:Process') { - const processMatch = PREFIXED.exec(id); - if (processMatch) { - this.processes.add(processMatch[1]!); - this.tallyOf(this.processBodies, processMatch[1]!).add(this.bodyClass(processMatch[2]!)); - } - return; - } - if (declared) { - const trigger = triggerOf(el); - const state = { type: el.$type, ...(trigger ? { trigger } : {}) }; - const heads = declared.headsOf(state); - if (!this.rejected && !IGNORED.some((type) => is(el, type)) && declared.rejects(state, id, heads)) this.rejected = true; - if (heads.some((head) => id.startsWith(head))) return; - } - const sample = this.sampleOf(el, id); - if (!sample) return; - read.samples++; - const prefixedMatch = PREFIXED.exec(id); - let fam = this.family.get(sample.family); - if (!fam) this.family.set(sample.family, (fam = { kindish: new Tally(), generic: new Tally(), prefixed: 0 })); - if (!prefixedMatch) { - if (!sample.spellsName) return; - this.tallyOf(this.byKey, sample.key).add(''); - if (sample.trigger) this.tallyOf(this.byKeyTrigger, `${sample.key}|${sample.trigger}`).add(''); - this.tallyOf(this.classByKey, sample.key).add('literal'); - fam.generic.add(''); - read.bareBodies.add(this.bodyClass(id)); - if (sample.flowNode) this.bareNodes++; - return; - } - read.prefixed++; - fam.prefixed++; - if (sample.flowNode) this.prefixedNodes++; - const prefix = prefixedMatch[1]!; - const body = prefixedMatch[2]!; - const cls = prefixClass(prefix, sample.typeNames, sample.trigger, sample.family); - this.tallyOf(this.byKey, sample.key).add(prefix); - if (sample.trigger) this.tallyOf(this.byKeyTrigger, `${sample.key}|${sample.trigger}`).add(prefix); - this.tallyOf(this.classByKey, sample.key).add(cls); - if (!(GENERIC.has(prefix.toLowerCase()) && sample.typeNames.some((typeName) => typeName.toLowerCase() === prefix.toLowerCase()))) this.classes.add(cls); - if (/^[a-z]/.test(prefix)) this.lowerFirst++; - else this.upperFirst++; - if (cls !== 'literal') fam.kindish.add(cls === 'triggerKind' ? 'triggerKind' : 'kind'); - else if (!this.isSpecific(prefix, sample.typeNames, sample.trigger, sample.family)) fam.generic.add(prefix); - if (sample.named) (sample.flowNode ? read.nodeBodies : read.otherBodies).add(this.bodyClass(body)); - } - - /** - * Decides the flow style of `type`. Glued flows in a file of flow12 ids vote for the longest - * glued prefix they start with. Ties never go by document order, except between scopes, which - * keep the first one counted. Hashed and numbered flows lend new flows their prefix and - * separator (flow12 -> flowAToB). - */ - private voteFlows(type: string, votes: FlowVotes): void { - const glued = [...votes.seps].filter(([prefix, sep]) => sep === '' && /[a-z]/.test(prefix)).map(([prefix]) => prefix).sort((first, second) => second.length - first.length); - const gluedTo = (id: string): string | undefined => glued.find((prefix) => id.startsWith(prefix) && /^[A-Z0-9]/.test(id.charAt(prefix.length))); - const voteNumbered = (prefix: string | undefined): void => { - votes.forms.add('numbered'); - if (prefix) { - this.tallyOf(votes.prefixes, 'numbered').add(prefix); - votes.uses.add(prefix); - } - }; - if (votes.glued.length && [...votes.seps.values()].includes('')) { - votes.count += votes.glued.length; - for (const id of votes.glued) voteNumbered(gluedTo(id)); - } - for (const id of votes.gluedNamed) { - const gluedPrefix = gluedTo(id); - if (gluedPrefix) voteNumbered(gluedPrefix); - } - for (const { el, id, source, target } of votes.joined) { - let sourceLabel: string | undefined; - let targetLabel: string | undefined; - const ends = { source: (): string => (sourceLabel ??= new ElementLabels(el.get('sourceRef')).label()), target: (): string => (targetLabel ??= new ElementLabels(el.get('targetRef')).label()) }; - const gluedPrefix = glued.find((candidate) => id.startsWith(candidate) && this.gluedJoinedOf(id, candidate, ends)); - if (gluedPrefix) voteNumbered(gluedPrefix); - else this.countFlow(votes, el, id, source, target); - } - if (!votes.count) return; - const form = votes.forms.top(MIN_EVIDENCE, (first, second) => FORM_ORDER.indexOf(first) < FORM_ORDER.indexOf(second)); - const prefix = form ? votes.prefixes.get(form)!.top(MIN_EVIDENCE, (first, second) => votes.uses.get(first) > votes.uses.get(second) || (votes.uses.get(first) === votes.uses.get(second) && first < second)) : undefined; - const scope = form === 'scopedTo' ? votes.scopes.top() : undefined; - const any = votes.any.top(MIN_EVIDENCE, (first, second) => this.alphabetical(first, second)); - let style: FlowStyle; - if (form && prefix && votes.forms.get(form) * 2 >= votes.count && (form !== 'scopedTo' || scope)) { - const sep = votes.seps.get(prefix); - const named = form === 'hash' || form === 'numbered'; - style = { form: named ? 'named' : form, prefix, ...(form === 'numbered' && sep !== undefined ? { sep } : {}), ...(scope ? { scope } : {}) }; - if (form === 'scopedTo') this.scopes.set(type, [...votes.scopes.counts.keys()].sort((first, second) => votes.scopes.get(second) - votes.scopes.get(first))); - this.flowVoted.add(type); - } else { - style = { form: 'named', prefix: any ?? 'Flow' }; - if (any !== undefined) this.flowVoted.add(type); - } - this.flows.set(type, style); - this.flowStyles.set(type, this.otherStyles(votes, style)); - } - - /** Counts a flow's form; one the length cap cut votes for its form too, else enough of them flip the file's form. */ - private countFlow(votes: FlowVotes, el: El, id: string, source: string | undefined, target: string | undefined): void { - const prefix = PREFIXED.exec(id)?.[1]; - if (prefix) votes.any.add(prefix); - const own = prefix ?? GLUED.exec(id)?.[1]; - if (own) votes.uses.add(own); - if (!source || !target) return; - const seen = this.flowForms(id, source, target); - if (prefix) { - let req: IdRequest | undefined; - const request = (): IdRequest => (req ??= connectionRequest(el.$type as 'bpmn:SequenceFlow' | 'bpmn:MessageFlow', source, target, { sourceLabel: new ElementLabels(el.get('sourceRef')).label(), targetLabel: new ElementLabels(el.get('targetRef')).label() })); - for (const form of ID_FORMS) if (!seen.some((seenForm) => seenForm.form === form) && this.cutOfForm(id, form, prefix, request)) seen.push({ form, prefix }); - } - for (const seenForm of seen) { - votes.forms.add(seenForm.form); - let tally = votes.prefixes.get(seenForm.form); - if (!tally) votes.prefixes.set(seenForm.form, (tally = new Tally())); - tally.add(seenForm.prefix); - if (seenForm.sep !== undefined && !votes.seps.has(seenForm.prefix)) votes.seps.set(seenForm.prefix, seenForm.sep); - if (seenForm.scope) votes.scopes.add(seenForm.scope); - } - } - - private flowForms(id: string, source: string, target: string): SeenFlow[] { - const match = PREFIXED.exec(id); - if (!match) { - const gluedMatch = GLUED.exec(id); - return gluedMatch ? [{ form: 'numbered', prefix: gluedMatch[1]!, sep: '' }] : []; - } - const prefix = match[1]!; - const rest = match[2]!; - if (HASH.test(rest)) return [{ form: 'hash', prefix }]; - if (/^\d+$/.test(rest)) return [{ form: 'numbered', prefix }]; - const out: SeenFlow[] = []; - const scoped = SCOPED_TO.exec(rest); - if (scoped) out.push({ form: 'scopedTo', prefix, scope: scoped[1]! }); - const fits = (candidate: string, sep: string): boolean => rest === candidate || (rest.startsWith(candidate) && isSuffix(candidate, rest.slice(candidate.length), sep)); - if (fits(`${lcfirst(stem(source))}To${ucfirst(stem(target))}`, '')) out.push({ form: 'stemTo', prefix }); - if (fits(`${source}_${target}`, '_')) out.push({ form: 'idPair', prefix }); - if (fits(`${stem(source)}_to_${stem(target)}`, '_')) out.push({ form: 'stemSnake', prefix }); - if (fits(`${source}_to_${target}`, '_')) out.push({ form: 'idSnake', prefix }); - return out; - } - - private bodyClass(body: string): BodyClass { - if (HASH.test(body)) return 'hash'; - if (/^\d+$/.test(body)) return 'numbered'; - const bareBody = body.replace(/_\d+$/, ''); - if (/^[A-Z][A-Za-z0-9]*$/.test(bareBody)) return 'pascal'; - if (/^[a-z][a-z0-9]*$/.test(bareBody)) return 'lower'; - if (/^[a-z][A-Za-z0-9]*$/.test(bareBody)) return 'camel'; - if (/^[a-z0-9]+(_[a-z0-9]+)+$/.test(bareBody)) return 'snake'; - if (/^[A-Z][A-Za-z0-9]*(_[A-Za-z0-9]+)+$/.test(bareBody)) return 'pascalSnake'; - return 'other'; - } - - /** A literal prefix that abbreviates its own kind (End, Sub) says nothing about other kinds. */ - private isSpecific(prefix: string, typeNames: string[], trigger: string | undefined, family: string): boolean { - const lowerPrefix = prefix.toLowerCase(); - if (lowerPrefix === family.toLowerCase() || GENERIC.has(lowerPrefix)) return false; - return typeNames.some((typeName) => typeName.toLowerCase().startsWith(lowerPrefix)) || (!!trigger && trigger.toLowerCase().startsWith(lowerPrefix)); - } - - private alphabetical(first: string, second: string): boolean { - return first < second; - } - - private tallyOf(map: Map>, key: string): Tally { - let tally = map.get(key); - if (!tally) map.set(key, (tally = new Tally())); - return tally; - } - - private folded(text: string): string { - return nameWords(text).join('').toLowerCase(); - } - - private sampleOf(el: El, id: string): Sample | undefined { - if (IGNORED.some((type) => is(el, type))) return undefined; - const name = el.get('name'); - const named = !!name && nameWords(name).length > 0; - const spellsName = named && this.folded(id) === this.folded(name!); - const def = kindOf(el); - if (def) { - const trigger = triggerOf(el); - return { key: def.kind, family: def.prefix, typeNames: kindTypeNames(def), ...(trigger && trigger !== 'none' ? { trigger } : {}), id, named, spellsName, flowNode: FLOW_NODE_FAMILIES.has(def.family) }; - } - const local = localName(el.$type); - return { key: el.$type, family: local, typeNames: [lcfirst(local)], id, named, spellsName, flowNode: false }; - } - - /** - * Whether `id` is a flow bpmn-cli named by its ends after a glued prefix in a snake_case or - * Pascal_Snake body, uncut, cut, or naming only its source whole (its target changed since). - * Every cut keeps the source's first letter after the prefix, which is tested first. - */ - private gluedJoinedOf(id: string, prefix: string, ends: { source: () => string; target: () => string }): boolean { - const fits = (base: string): boolean => id === base || (id.startsWith(base) && isSuffix(base, id.slice(base.length), '_')); - for (const [join, slug] of [['_to_', snakeSlug], ['_To_', pascalSnakeSlug]] as const) { - if (!id.includes(join) || id.charAt(prefix.length) !== slug(ends.source()).charAt(0)) continue; - if (id.startsWith(`${prefix}${slug(ends.source())}${join}`)) return true; - const at = (max: number): string => new IdFitter().pair(prefix, slug(ends.source()), join, slug(ends.target()), max); - const uncut = at(Infinity); - if (fits(uncut) || (uncut.length > MAX_ID - 4 && [0, 1, 2, 3, 4].some((trim) => fits(at(MAX_ID - trim))))) return true; - } - return false; - } - - private otherStyles(votes: FlowVotes, style: FlowStyle): FlowStyle[] { - const out = new Map(); - const key = (flowStyle: FlowStyle): string => (flowStyle.form === 'named' ? `named|${new IdFitter().namedHead(flowStyle.prefix, flowStyle.sep).head}` : `${flowStyle.form}|${flowStyle.prefix}|${flowStyle.scope ?? ''}`); - const add = (flowStyle: FlowStyle): void => { - if (!out.has(key(flowStyle))) out.set(key(flowStyle), flowStyle); - }; - add(style); - for (const prefix of votes.any.counts.keys()) add({ form: 'named', prefix }); - for (const [prefix, sep] of votes.seps) add({ form: 'named', prefix, sep }); - for (const [form, tally] of votes.prefixes) { - if (form === 'hash' || form === 'numbered' || form === 'named') continue; - for (const prefix of tally.counts.keys()) { - if (form === 'scopedTo') for (const scope of votes.scopes.counts.keys()) add({ form, prefix, scope }); - else add({ form, prefix }); - } - } - out.delete(key(style)); - return [...out.values()]; - } - - /** - * Whether a flow id is a cut of a form built from its ends, so that it still votes for it. - * A cut keeps the join, and a stemTo source begins in lower case, which is tested first. A - * suffix of up to 4 characters cut the base; a glued one may be any of the last digits. - */ - private cutOfForm(id: string, form: IdForm, prefix: string, request: () => IdRequest): boolean { - const rest = id.slice(prefix.length + 1); - const join = FORM_JOIN[form]; - if (id.length > MAX_ID || !rest.includes(join) || (form === 'stemTo' && /^[A-Z]/.test(rest) && !rest.startsWith(join))) return false; - const req = request(); - if (form === 'stemTo' || form === 'stemSnake') { - const { sourceEnd, targetEnd } = new IdFitter().stemEnds(form, req); - if (!beginAlike(sourceEnd, targetEnd) && rest.charAt(0) !== sourceEnd.charAt(0) && !rest.startsWith(join)) return false; - } - const fitter = new IdFitter(); - if (fitter.idForm(form, `${prefix}_`, req, Infinity).length <= MAX_ID - 4) return false; - const sep = form === 'stemTo' ? '' : '_'; - if (id === fitter.idForm(form, `${prefix}_`, req, MAX_ID)) return true; - const digits = (sep === '' ? /\d+$/ : /_\d+$/).exec(id)?.[0] ?? ''; - const ks: number[] = []; - for (let digitCount = sep === '' ? 1 : digits.length; digitCount <= Math.min(digits.length, 4); digitCount++) ks.push(digitCount); - if (sep === '' && digits.length <= 4 && id.endsWith(`${SUFFIX_WORD}${digits}`)) ks.push(SUFFIX_WORD.length + digits.length); - for (const digitCount of ks) { - const suffix = id.slice(id.length - digitCount); - if (/^_?0/.test(suffix.slice(suffix.startsWith(SUFFIX_WORD) ? SUFFIX_WORD.length : 0))) continue; - if (id.slice(0, -digitCount) === fitter.idForm(form, `${prefix}_`, req, MAX_ID - digitCount)) return true; - } - return false; - } -} diff --git a/src/domain/document/ids/Tally.ts b/src/domain/document/ids/Tally.ts deleted file mode 100644 index 5a6c5a2c..00000000 --- a/src/domain/document/ids/Tally.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * One element is not a convention: a learned rule needs two ids that follow it. - * In a mostly prefixed file one id is enough for the prefix of its own kind and family. - */ -export const MIN_EVIDENCE = 2; - -export class Tally { - readonly counts = new Map(); - add(key: TKey, amount = 1): void { - this.counts.set(key, (this.counts.get(key) ?? 0) + amount); - } - get(key: TKey): number { - return this.counts.get(key) ?? 0; - } - get total(): number { - let total = 0; - for (const count of this.counts.values()) total += count; - return total; - } - /** - * The most frequent key with at least `min` votes. A tie goes to `prefers`, else to the first - * counted; flow votes pass `prefers` so document order never tips them. - */ - top(min = MIN_EVIDENCE, prefers?: (first: TKey, second: TKey) => boolean): TKey | undefined { - let best: TKey | undefined; - let bestCount = 0; - for (const [key, count] of this.counts) { - if (count > bestCount || (count === bestCount && best !== undefined && prefers?.(key, best))) { - best = key; - bestCount = count; - } - } - return bestCount >= min ? best : undefined; - } -} diff --git a/src/domain/document/utils/elements.ts b/src/domain/document/utils/elements.ts index fa789412..3b68e36c 100644 --- a/src/domain/document/utils/elements.ts +++ b/src/domain/document/utils/elements.ts @@ -31,7 +31,7 @@ export function idOf(el: El): string { return el.get('id') ?? ''; } -/** `userTask Activity_Check "Check"` */ +/** `userTask userTask_check "Check"` */ export function labelOf(el: El): string { const name = el.get('name'); return `${kindLabel(el)} ${el.get('id')}${name ? ` "${name}"` : ''}`; diff --git a/src/domain/document/utils/id-words.ts b/src/domain/document/utils/id-words.ts index c60be8a7..b57bfd4e 100644 --- a/src/domain/document/utils/id-words.ts +++ b/src/domain/document/utils/id-words.ts @@ -5,13 +5,13 @@ export const ucfirst = (text: string): string => text.charAt(0).toUpperCase() + export const localName = (type: string): string => type.slice(type.indexOf(':') + 1); export const PREFIXED = /^([A-Za-z][A-Za-z0-9]*)_(.+)$/; -export const GLUED = /^([A-Za-z]+)(\d+)$/; +const GLUED = /^([A-Za-z]+)(\d+)$/; /** The Camunda Modeler's first start event says nothing about a file's conventions. */ -export const TOOL_DEFAULTS = new Set(['StartEvent_1']); -export const HASH = /^[01][0-9a-z]{6}$/; +const TOOL_DEFAULTS = new Set(['StartEvent_1']); +const HASH = /^[01][0-9a-z]{6}$/; -export function stem(id: string): string { +function stem(id: string): string { const underscoreIndex = id.indexOf('_'); return underscoreIndex === -1 ? id : id.slice(underscoreIndex + 1); } @@ -43,7 +43,7 @@ function hasWord(text: string): boolean { return (text.match(/[A-Za-z]{4,}/g) ?? []).some((run) => /[g-z]/i.test(run)); } -/** The words that tie an unnamed element's id to its place (Gateway_AfterCheckInvoice). */ +/** The words that tie an unnamed element's id to its place (gateway_afterCheckInvoice). */ export type ContextWord = 'After' | 'Before' | 'On' | 'In'; export const CONTEXT_WORDS = new Set(['after', 'before', 'on', 'in']); @@ -61,26 +61,6 @@ export function beginAlike(one: string, other: string): boolean { return first(one) === first(other); } -export type PrefixClass = 'kind' | 'triggerKind' | 'literal'; - -/** - * Type names that are also their family's usual generic prefix: `Task_` on a plain task may be - * the old modeler's type name or a `Task_` for every task, so it counts as the family's prefix. - */ -export const GENERIC = new Set(['task']); - -/** - * The bpmn-cli prefix of the family is a literal even where it is the type name (Lane_, - * Message_): it says nothing about type-named prefixes. - */ -export function prefixClass(prefix: string, typeNames: string[], trigger: string | undefined, family: string): PrefixClass { - const lowerPrefix = prefix.toLowerCase(); - if (lowerPrefix === family.toLowerCase() || GENERIC.has(lowerPrefix)) return 'literal'; - if (typeNames.some((typeName) => typeName.toLowerCase() === lowerPrefix)) return 'kind'; - if (trigger && typeNames.some((typeName) => `${trigger}${typeName}`.toLowerCase() === lowerPrefix)) return 'triggerKind'; - return 'literal'; -} - /** * A word for an unnamed element of a kind (MessageStart, Exclusive, UserTask, Main). With a * context the prefix says enough for every kind but the events, so it is ''. diff --git a/src/domain/document/utils/requests.ts b/src/domain/document/utils/requests.ts index dd50ec70..ee01e39f 100644 --- a/src/domain/document/utils/requests.ts +++ b/src/domain/document/utils/requests.ts @@ -1,21 +1,8 @@ import type { KindDef } from '../Kinds.js'; -import { lcfirst, localName } from './id-words.js'; - -/** - * The kind and its BPMN type, as a type-named prefix could use them. A data reference's kind is - * the type it stands for, so its own type comes first (`dataObjectReference_`, `dataObject_`). - */ -export function kindTypeNames(def: KindDef): string[] { - const type = lcfirst(localName(def.type)); - return [...new Set(type === `${def.kind}Reference` ? [type, def.kind] : [def.kind, type])]; -} export interface IdRequest { /** a kind of `bpmn kinds` or a BPMN type (bpmn:Message, bpmn:LaneSet) */ key: string; - prefix: string; - /** lowerCamel names a type-named prefix would use (serviceTask_, ServiceTask_) */ - typeNames: string[]; trigger?: string; name?: string; /** @@ -32,20 +19,14 @@ export interface IdRequest { target?: string; sourceLabel?: string; targetLabel?: string; - /** - * a word after the body that a cut keeps (a backing bpmn:DataObject's `Object`): - * the body is cut so the word fits within the 64 characters - */ - tailWord?: string; } export function kindRequest(def: KindDef, opts: { trigger?: string; name?: string; context?: string; anchorKind?: string } = {}): IdRequest { - return { key: def.kind, prefix: def.prefix, typeNames: kindTypeNames(def), ...opts }; + return { key: def.kind, ...opts }; } -export function typeRequest(type: string, opts: { name?: string; context?: string; prefix?: string } = {}): IdRequest { - const { prefix, ...rest } = opts; - return { key: type, prefix: prefix ?? localName(type), typeNames: [lcfirst(localName(type))], ...rest }; +export function typeRequest(type: string, opts: { name?: string; context?: string } = {}): IdRequest { + return { key: type, ...opts }; } export function connectionRequest( @@ -55,7 +36,7 @@ export function connectionRequest( opts: { name?: string; sourceLabel?: string; targetLabel?: string } = {}, ): IdRequest { const { name, ...labels } = opts; - return { key: type, prefix: 'Flow', typeNames: [lcfirst(localName(type))], source, target, ...(name ? { name } : {}), ...labels }; + return { key: type, source, target, ...(name ? { name } : {}), ...labels }; } export const CONNECTIONS = new Set(['bpmn:SequenceFlow', 'bpmn:MessageFlow']); diff --git a/src/domain/document/utils/slug.ts b/src/domain/document/utils/slug.ts index 8839fc3f..f5575afa 100644 --- a/src/domain/document/utils/slug.ts +++ b/src/domain/document/utils/slug.ts @@ -75,7 +75,7 @@ export function hasIdWords(name: string | undefined): boolean { const cap = (word: string): string => word.charAt(0).toUpperCase() + word.slice(1); /** A join and / or number tells a slug apart from its neighbours, so it survives a cut. */ -const KEPT_TAIL = /(?:(?<=[a-z0-9])Join|_[Jj]oin)?(?:(?<=[A-Za-z])\d+|_\d+)?$/; +const KEPT_TAIL = /(?:(?<=[a-z0-9])Join|[_-][Jj]oin)?(?:(?<=[A-Za-z])\d+|[_-]\d+)?$/; /** * Cuts at the last word boundary that fits, keeping a join or number tail. A cut keeps a letter @@ -178,12 +178,16 @@ export function snakeSlug(name: string | undefined): string { ); } -export function pascalSnakeSlug(name: string | undefined): string { - return cut(nameWords(name).map(cap).join('_')); +export function kebabSlug(name: string | undefined): string { + return cut( + nameWords(name) + .map((word) => word.toLowerCase()) + .join('-'), + ); } /** `step1No2`, never `step12`, which reads as the id of Step 12. */ -export const SUFFIX_WORD = 'No'; +const SUFFIX_WORD = 'No'; export function withSuffix(base: string, sep: string, suffixNumber: number, cut: (max: number) => string = (max) => cutAt(base, max)): string { const fit = (suffix: string): string => (base.length + suffix.length <= MAX_ID ? base : cut(MAX_ID - suffix.length)); @@ -197,7 +201,7 @@ export function withSuffix(base: string, sep: string, suffixNumber: number, cut: /** `either`: a flow's id, whose separator the reader does not know. */ export function isSuffix(base: string, rest: string, sep: string | 'either'): boolean { const word = /\d$/.test(base) ? `(?:${SUFFIX_WORD})?` : ''; - if (sep === 'either') return new RegExp(`^(?:_|${word})\\d+$`).test(rest); + if (sep === 'either') return new RegExp(`^(?:[_-]|${word})\\d+$`).test(rest); return new RegExp(sep === '' ? `^${word}\\d+$` : `^${sep}\\d+$`).test(rest); } diff --git a/src/domain/editing/connections/BranchSplit.ts b/src/domain/editing/connections/BranchSplit.ts index ffc06db5..7abe3b00 100644 --- a/src/domain/editing/connections/BranchSplit.ts +++ b/src/domain/editing/connections/BranchSplit.ts @@ -4,7 +4,6 @@ import { modelError, usageError } from '../../document/utils/errors.js'; import { gatewayKindList, parseKind } from '../../document/Kinds.js'; import { is, type El, idOf } from '../../document/utils/moddle.js'; import { ChangeSet } from '../../document/ChangeSet.js'; -import { IdStyle } from '../../document/ids/IdStyle.js'; import { ElementLabels } from '../../document/ids/ElementLabels.js'; import { CommandParser } from '../../commands/CommandParser.js'; import { laneOf } from '../../document/utils/lanes.js'; @@ -49,22 +48,18 @@ export class BranchSplit { } /** - * op.joinId, else `_join` in the file's id style. `base` (the id it wanted) + * op.joinId, else `Join` in the id conventions. `base` (the id it wanted) * marks it generated, so a retype in the same batch gives it the new prefix. - * A join named by declared conventions rather than the file's style is recorded for the - * result's `conventions:` line. */ private joinIdFor(command: SplitIntoBranches, gateway: El, changes: ChangeSet, kind: string): { id: string; base?: string } { if (command.joinId) return { id: command.joinId }; const gatewayId = idOf(gateway); const base = this.edit.idStyle.joinId(gatewayId, kind, new ElementLabels(gateway).label()); - const sep = this.edit.idStyle.suffixSep(kind); + const sep = this.edit.idStyle.suffixSep(); const id = this.edit.allocator.suffixed(base, sep); if (id !== base) { changes.warn({ code: 'W_ID_SUFFIXED', message: `Id ${base} is already taken; using ${id}`, element: id, hint: 'Pass joinId to choose the join id yourself.' }); } - const style = this.edit.idStyle; - if (style.declared && style.explain(IdStyle.keyRequest(kind)).source === 'lint' && base !== style.learnedOnly().joinId(gatewayId, kind)) this.edit.allocator.recordDeclared(id); return { id, base }; } diff --git a/src/domain/editing/connections/FlowIdFollower.ts b/src/domain/editing/connections/FlowIdFollower.ts index ac6c906f..bd183526 100644 --- a/src/domain/editing/connections/FlowIdFollower.ts +++ b/src/domain/editing/connections/FlowIdFollower.ts @@ -7,7 +7,7 @@ import { hasIdWords, slugify } from '../../document/utils/slug.js'; import { is, many, type El, idOf } from '../../document/utils/moddle.js'; /** - * Keeps speaking flow ids (Flow_CheckInvoiceToDone) true: a flow named after its ends is + * Keeps speaking flow ids (flow_checkInvoiceToDone) true: a flow named after its ends is * renamed after its new ends when they change. Other ids stay; with `--keep-flow-ids` * the document's flows keep theirs and get reported as stale. */ @@ -24,8 +24,8 @@ export class FlowIdFollower { } /** - * Whether `id` is one any id style (also of earlier versions, with a collision suffix) - * gives a flow between `source` and `target`, by their current or former names and ids. @internal + * Whether `id` is the one the id conventions give a flow between `source` and `target` (also + * with a collision suffix), by their current or former names and ids. @internal */ namesEnds(id: string | undefined, source: El, target: El): boolean { if (!id) return false; diff --git a/src/domain/editing/connections/MovedFlows.ts b/src/domain/editing/connections/MovedFlows.ts index a1641de7..ce02c86e 100644 --- a/src/domain/editing/connections/MovedFlows.ts +++ b/src/domain/editing/connections/MovedFlows.ts @@ -167,7 +167,7 @@ export class MovedFlows { /** * Tells on a put-back flow's change entry which bridge kept its label: - * `(was -> C; its name stays with the bridge Flow_AToB)`. + * `(was -> C; its name stays with the bridge flow_aToB)`. */ private reportBridgesKept(before: Map): void { const changes = this.changes; diff --git a/src/domain/editing/elements/Artifacts.ts b/src/domain/editing/elements/Artifacts.ts index 107bc386..f7e0081a 100644 --- a/src/domain/editing/elements/Artifacts.ts +++ b/src/domain/editing/elements/Artifacts.ts @@ -100,18 +100,9 @@ export class Artifacts { } } - /** - * Where the file's id prefix is the same for reference and backing element, the - * backing id gets `Object` / `Store` (as a tail word when the name gets cut). - */ + /** The backing element is named like its reference: dataObjectItem_invoice for dataObject_invoice. */ private backingRequest(type: 'bpmn:DataObject' | 'bpmn:DataStore', refId: string, name: string | undefined): IdRequest { - const context = speakingStem(refId) ?? ''; - const req = typeRequest(type, { ...(name ? { name } : {}), context }); - if (this.edit.idStyle.derivedBase(req) !== refId) return req; - const word = type === 'bpmn:DataObject' ? 'Object' : 'Store'; - const worded = typeRequest(type, name ? { name: `${name} ${word}` } : { context: `${context} ${word}` }); - const id = this.edit.idStyle.derivedBase(worded); - return id !== refId && id.toLowerCase().endsWith(word.toLowerCase()) ? worded : { ...req, tailWord: word }; + return typeRequest(type, { ...(name ? { name } : {}), context: speakingStem(refId) ?? '' }); } private createCategoryValue(context: string, name: string | undefined): El { diff --git a/src/domain/editing/events/RootElements.ts b/src/domain/editing/events/RootElements.ts index 632fc585..6d0a4fc1 100644 --- a/src/domain/editing/events/RootElements.ts +++ b/src/domain/editing/events/RootElements.ts @@ -32,7 +32,7 @@ export class RootElements { let el = existing.find((candidate) => idOf(candidate) === key) ?? existing.find((candidate) => candidate.get('name') === key); let created = false; if (!el) { - el = doc.create(type, { id: this.edit.allocator.allocate(typeRequest(type, { name: key, prefix: ROOT_PREFIX[type] })).id, name: key }); + el = doc.create(type, { id: this.edit.allocator.allocate(typeRequest(type, { name: key })).id, name: key }); addTo(doc.definitions, 'rootElements', el); created = true; } diff --git a/src/domain/editing/ids/IdRename.ts b/src/domain/editing/ids/IdRename.ts index 588166a7..f40579d6 100644 --- a/src/domain/editing/ids/IdRename.ts +++ b/src/domain/editing/ids/IdRename.ts @@ -59,24 +59,23 @@ export class IdRename { /** * An id the batch generated is nobody's API yet, so where a retype leaves it a stale * prefix it takes the id of what the element is now (serviceTask_check -> userTask_check), - * and so do the flows named after it. Any other id is recorded for W_ID_PREFIX_STALE. + * and so do the flows named after it. Under declared conventions any other id is recorded for W_ID_PREFIX_STALE. * The stale prefix is judged from the id it wanted: a collision suffix under the old prefix is * not the new one's. */ recordState(el: El, before: IdState, wasLabel: string): void { const style = this.edit.idStyle; - if (!style.declared) return; const id = idOf(el); const allocator = this.edit.allocator; const base = allocator.generatedBase(id); if (base === undefined) { - this.edit.recordIdState(id, before, wasLabel); + if (style.declared) this.edit.recordIdState(id, before, wasLabel); return; } const stale = style.stalePrefix(base, before, IdRename.stateOf(el), IdRename.requestOf(el)); if (!stale) return; const wanted = cutAt(stale.suggested, MAX_ID); - const next = allocator.regenerated(id, wanted, style.suffixSep(kindOf(el)?.kind ?? el.$type)); + const next = allocator.regenerated(wanted, style.suffixSep()); const flows = new FlowIdFollower(this.edit).namedAfter(el).map((flow) => ({ flow, was: `${idOf(flow.get('sourceRef'))} -> ${idOf(flow.get('targetRef'))}` })); this.rename(el, next); allocator.markGenerated(next, wanted); @@ -98,7 +97,7 @@ export class IdRename { const el = doc.get(id); const stale = el && style.stalePrefix(id, before, IdRename.stateOf(el), IdRename.requestOf(el)); if (!el || !stale) continue; - const sep = style.suffixSep(kindOf(el)?.kind ?? el.$type); + const sep = style.suffixSep(); const suggested = this.edit.allocator.suffixed(stale.suggested, sep, (taken) => hinted.has(taken), () => stale.suggested); hinted.claim(suggested); const rule = `element-id-naming (${this.edit.conventions?.source ?? 'the host'})`; diff --git a/src/domain/editing/properties/NestedHosts.ts b/src/domain/editing/properties/NestedHosts.ts index aa640d80..d7eac2c9 100644 --- a/src/domain/editing/properties/NestedHosts.ts +++ b/src/domain/editing/properties/NestedHosts.ts @@ -29,7 +29,7 @@ export class NestedHosts { return nestedHost ? `${nestedHost.prefix}.` : ''; } - /** Its id, or `the loop characteristics of Activity_X`. */ + /** Its id, or `the loop characteristics of task_x`. */ labelOf(host: El): string { const nestedHost = this.nested.get(host); return nestedHost ? `the ${SLOT_TEXT[nestedHost.slot]}${nestedHost.prefix !== nestedHost.slot ? ` ${nestedHost.prefix}` : ''} of ${idOf(nestedHost.owner)}` : idOf(host); diff --git a/src/domain/validation/profiles/c7/C7Rules.ts b/src/domain/validation/profiles/c7/C7Rules.ts index 78fbb4ae..829a211f 100644 --- a/src/domain/validation/profiles/c7/C7Rules.ts +++ b/src/domain/validation/profiles/c7/C7Rules.ts @@ -20,7 +20,7 @@ export abstract class C7Rules extends EngineRules { return super.addressOf(el, owner); } - /** `userTask Activity_X`, `the multiInstanceLoopCharacteristics of userTask Activity_X` */ + /** `userTask userTask_x`, `the multiInstanceLoopCharacteristics of userTask userTask_x` */ protected describe(el: El, owner: El | undefined): string { const id = idOf(el); const kind = this.kit.isBpmnElement(el) ? this.kit.kindLabel(el).replace(/^bpmn:/, '') : el.$type; diff --git a/src/domain/validation/profiles/c8/C8Rules.ts b/src/domain/validation/profiles/c8/C8Rules.ts index 974c4afa..133ba5fe 100644 --- a/src/domain/validation/profiles/c8/C8Rules.ts +++ b/src/domain/validation/profiles/c8/C8Rules.ts @@ -7,7 +7,7 @@ import { isFeel } from '../../utils/c8-value.js'; /** Base of the Camunda 8 rule classes: how Zeebe reads zeebe content, and how hints rebuild it. */ export abstract class C8Rules extends EngineRules { - /** `userTask Activity_X`, `the multiInstanceLoopCharacteristics of userTask Activity_X` */ + /** `userTask userTask_x`, `the multiInstanceLoopCharacteristics of userTask userTask_x` */ protected describe(el: El, owner?: El): string { const id = idOf(el); const kind = this.kit.isBpmnElement(el) ? lowerFirst(this.kit.kindLabel(el).replace(/^bpmn:/, '')) : el.$type; diff --git a/src/domain/validation/structure/StructureFindings.ts b/src/domain/validation/structure/StructureFindings.ts index 7efccc3c..d7472afb 100644 --- a/src/domain/validation/structure/StructureFindings.ts +++ b/src/domain/validation/structure/StructureFindings.ts @@ -22,7 +22,7 @@ export class StructureFindings { return name ? String(name) : undefined; } - /** `userTask Activity_X "name"` */ + /** `userTask userTask_x "name"` */ describe(el: El): string { const name = this.nameOf(el); return `${kindLabel(el)} ${shownId(el)}${name ? ` "${name}"` : ''}`; diff --git a/src/index.ts b/src/index.ts index 2e708850..a7c5bf2a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -60,7 +60,6 @@ const NO_FILES = new NoDocumentFiles(); /* the in-memory API: strings in, strings / data out */ export { mutateDoc, layoutDoc, checkDoc, applyToXml, newXml, layoutXml, validateXml, viewXml, showXml, conventionsXml, metricsXml, findXml, extensionsXml, type EditOptions, type EditResult, type EditReport, type LayoutXmlOptions, type ShownView, type ValidateXmlOptions, type ViewOptions } from './api.js'; export { type MutationReport, type MutationReportLike, type MutationSummary, type ValidationReport, type WarningReport } from './adapters/in/report/command-report.js'; -export { conventionsLines } from './adapters/in/report/utils/mutation-lines.js'; export function mutationReport(result: MutationResult): MutationReport { return new MutationReportBuilder(result).report(); } export function renderMutation(result: MutationReportLike, opts: { dryRun?: boolean } = {}): string { return new MutationText(result, opts).text(); } export function mutationSummary(report: MutationReportLike): MutationSummary { return new MutationSummaryBuilder(report).summary(); } @@ -110,7 +109,7 @@ export type { InsertSpace, TidyShapes, } from './domain/commands/types.js'; -export { type ConventionsReport, type MutationOptions, type MutationResult } from './domain/batch/types.js'; +export { type MutationOptions, type MutationResult } from './domain/batch/types.js'; export { LAYOUT_MODES, type LayoutMode, type LayoutStatus } from './domain/diagram/types.js'; export { type CheckOptions, type CheckResult } from './application/port/in/ValidateDocumentUseCase.js'; export { type LayoutDocOptions } from './application/port/in/RelayoutDiagramUseCase.js'; @@ -138,7 +137,8 @@ export { type ConventionsView, type ConventionsRow, type Undeclared } from './do /** * The id conventions element-id-naming declares, with a W_CONFIG_IGNORED note for each value - * new ids cannot follow; `conventions` is undefined where nothing is declared. + * new ids cannot follow; `conventions` is undefined where nothing is declared. `naming` is the + * plugin's `naming` namespace (@miragon/bpmnlint-plugin-rules 0.19 or later). */ export function idConventionsFromLint(setting: BpmnlintRuleSetting | undefined, naming: IdNamingHelpers, opts: { source?: string } = {}): { conventions?: IdConventions; notes: ValidatorFinding[] } { return new IdConventionsReader(naming, opts.source).read(setting); diff --git a/test/adapters/in/report/report-delta.test.ts b/test/adapters/in/report/report-delta.test.ts index 990a7b71..ddb6d24d 100644 --- a/test/adapters/in/report/report-delta.test.ts +++ b/test/adapters/in/report/report-delta.test.ts @@ -89,7 +89,7 @@ describe('warnings as a delta', () => { it('a new document reports its warnings (there was no file before)', async () => { const created = await newXml({ processName: 'Fresh' }); - expect(codes(created.result.warnings.added)).toEqual(['W_NO_START Process_Fresh', 'W_NO_END Process_Fresh']); + expect(codes(created.result.warnings.added)).toEqual(['W_NO_START fresh', 'W_NO_END fresh']); expect(created.result.warnings.preexistingCount).toBe(0); }); diff --git a/test/adapters/out/bpmnlint/bpmnlint-config.test.ts b/test/adapters/out/bpmnlint/bpmnlint-config.test.ts index 871e98e2..15cfb300 100644 --- a/test/adapters/out/bpmnlint/bpmnlint-config.test.ts +++ b/test/adapters/out/bpmnlint/bpmnlint-config.test.ts @@ -54,7 +54,7 @@ describe('resolveBpmnlintConfig: the effective rules', () => { it('awaits a resolver that answers with promises; a rejected rule is left out with its reason', async () => { const { Linter, resolver } = bpmnlintEngine(); - const xml = await model([...LINE, { op: 'add', kind: 'task', after: 'Activity_Check' }]); + const xml = await model([...LINE, { op: 'add', kind: 'task', after: 'userTask_check' }]); const rc = { extends: 'bpmnlint:recommended' }; const sync = await resolveBpmnlintConfig({ Linter, resolver }, rc); const asyncResolver: BpmnlintResolver = { diff --git a/test/adapters/out/bpmnlint/bpmnlint-validator.test.ts b/test/adapters/out/bpmnlint/bpmnlint-validator.test.ts index 97e83194..cc1ec0a2 100644 --- a/test/adapters/out/bpmnlint/bpmnlint-validator.test.ts +++ b/test/adapters/out/bpmnlint/bpmnlint-validator.test.ts @@ -20,25 +20,25 @@ describe('bpmnlintValidator: findings', () => { it('maps a report to code, element, severity and a key without array indices; nothing else of the report', async () => { const engine = bpmnlintEngine(); - const xml = await model([...LINE, { op: 'add', kind: 'task', after: 'Activity_Check' }]); + const xml = await model([...LINE, { op: 'add', kind: 'task', after: 'userTask_check' }]); const lintValidator = bpmnlintValidator(engine, await resolveBpmnlintConfig(engine, { extends: 'bpmnlint:recommended', rules: { 'fake-join': 'info', 'label-required': 'warn' } }), { detail: 'test' }); expect(lintValidator).toMatchObject({ name: 'bpmnlint', detail: 'test', blocks: false, covers: BPMNLINT_COVERS }); const found = (await lintValidator.validate!(xml, ctx(xml))) as ValidatorFinding[]; - expect(found).toEqual([{ severity: 'warning', code: 'label-required', message: 'Element is missing label/name', element: 'Activity_AfterCheck', key: 'Activity_AfterCheck|name|Element is missing label/name' }]); + expect(found).toEqual([{ severity: 'warning', code: 'label-required', message: 'Element is missing label/name', element: 'task_afterCheck', key: 'task_afterCheck|name|Element is missing label/name' }]); }); it('the key of a finding with a path leaves out the array indices: removing an earlier sibling changes nothing', async () => { const dir = repo({ extends: 'plugin:camunda-compat/camunda-cloud-8-10', moddleExtensions: { zeebe: 'zeebe-bpmn-moddle/resources/zeebe.json' } }); - const xml = await model(LINE).then((modelXml) => applyToXml(modelXml, [{ op: 'ext', id: 'Activity_Check', action: 'add', type: 'zeebe:input', attrs: { source: '=a', target: 'a' } }])); + const xml = await model(LINE).then((modelXml) => applyToXml(modelXml, [{ op: 'ext', id: 'userTask_check', action: 'add', type: 'zeebe:input', attrs: { source: '=a', target: 'a' } }])); // a second input without target (the io-mapping rule reports it with its index in the path) const file = join(dir, 'c8.bpmn'); writeFileSync(file, xml.xml.replace('', '\n ')); const before = lintOf((await checkFile(file, { lintProjectCode: true })).validation.errors); - expect(codes(before)).toContain('camunda-compat/io-mapping Activity_Check'); - const mutated = await mutateFile(file, [{ op: 'ext', id: 'Activity_Check', action: 'remove', type: 'zeebe:input[target=a]' }], { dryRun: true }); + expect(codes(before)).toContain('camunda-compat/io-mapping userTask_check'); + const mutated = await mutateFile(file, [{ op: 'ext', id: 'userTask_check', action: 'remove', type: 'zeebe:input[target=a]' }], { dryRun: true }); const report = mutationReport(mutated); - expect(codes(report.warnings.added)).not.toContain('camunda-compat/io-mapping Activity_Check'); - expect(codes(report.warnings.resolved)).not.toContain('camunda-compat/io-mapping Activity_Check'); + expect(codes(report.warnings.added)).not.toContain('camunda-compat/io-mapping userTask_check'); + expect(codes(report.warnings.resolved)).not.toContain('camunda-compat/io-mapping userTask_check'); }); it('a rule that throws (check or factory) is one W_LINT_FAILED; the others report, nothing goes to stderr', async () => { @@ -53,14 +53,14 @@ describe('bpmnlintValidator: findings', () => { }; const engine: BpmnlintEngine = { Linter, resolver: { resolveConfig: (pkg, name) => resolver.resolveConfig(pkg, name), resolveRule: (pkg, name) => (pkg === 'bpmnlint-plugin-local' ? local[name] : resolver.resolveRule(pkg, name)) } }; const resolved = await resolveBpmnlintConfig(engine, { rules: { 'local/boom': 'warn', 'local/bad-options': ['error'], 'local/no-check': 'warn', 'local/leave': 'warn', 'label-required': 'error' } }); - const xml = await model([...LINE, { op: 'add', kind: 'task', after: 'Activity_Check' }]); + const xml = await model([...LINE, { op: 'add', kind: 'task', after: 'userTask_check' }]); const error = vi.spyOn(console, 'error').mockImplementation(() => undefined); try { const found = (await bpmnlintValidator(engine, resolved).validate!(xml, ctx(xml))) as ValidatorFinding[]; expect(error).not.toHaveBeenCalled(); expect(found.map((finding) => `${finding.code}: ${finding.message}`).sort()).toEqual([ 'W_LINT_FAILED: bpmnlint rule local/bad-options failed: Cannot read properties of undefined (reading \'size\'); its findings are left out', - 'W_LINT_FAILED: bpmnlint rule local/boom failed: kaboom on Event_Done; its findings are left out', + 'W_LINT_FAILED: bpmnlint rule local/boom failed: kaboom on endEvent_done; its findings are left out', 'W_LINT_FAILED: bpmnlint rule local/leave failed: on leave; its findings are left out', 'W_LINT_FAILED: bpmnlint rule local/no-check failed: the rule has no check; its findings are left out', 'label-required: Element is missing label/name', @@ -92,7 +92,7 @@ describe('bpmnlintValidator: findings', () => { }; const engine: BpmnlintEngine = { Linter, resolver: { resolveConfig: (pkg, name) => resolver.resolveConfig(pkg, name), resolveRule: (pkg, name) => (pkg === 'bpmnlint-plugin-local' ? local[name] : resolver.resolveRule(pkg, name)) } }; const resolved = await resolveBpmnlintConfig(engine, { rules: Object.fromEntries([...Object.keys(local).map((name) => [`local/${name}`, 'warn']), ['label-required', 'error']]) }); - const xml = await model([...LINE, { op: 'add', kind: 'task', after: 'Activity_Check' }]); + const xml = await model([...LINE, { op: 'add', kind: 'task', after: 'userTask_check' }]); const unhandled = vi.fn(); process.on('unhandledRejection', unhandled); try { @@ -104,8 +104,8 @@ describe('bpmnlintValidator: findings', () => { 'W_LINT_FAILED: bpmnlint rule local/async-factory failed: the rule factory returned a promise (bpmnlint rules are synchronous); its findings are left out', 'W_LINT_FAILED: bpmnlint rule local/async-factory-ok failed: the rule factory returned a promise (bpmnlint rules are synchronous); its findings are left out', 'W_LINT_FAILED: bpmnlint rule local/async-later failed: later boom; its findings are left out', - 'label-required Activity_AfterCheck: Element is missing label/name', - 'local/async-ok Event_Done: reported before the first await', + 'label-required task_afterCheck: Element is missing label/name', + 'local/async-ok endEvent_done: reported before the first await', ]); } finally { process.off('unhandledRejection', unhandled); @@ -132,7 +132,7 @@ describe('bpmnlintValidator: findings', () => { it('a moddle descriptor BpmnModdle rejects (a prefix or uri already defined, an extends of an unknown type) is left out alone: W_LINT_UNAVAILABLE, the other rules run', async () => { const engine = bpmnlintEngine(); - const xml = await model([...LINE, { op: 'add', kind: 'task', in: 'Process_Order' }]); + const xml = await model([...LINE, { op: 'add', kind: 'task', in: 'order' }]); const ext = (prefix: string, more: object = {}) => ({ name: prefix.toUpperCase(), prefix, uri: `http://example.org/${prefix}`, types: [], ...more }); const moddleExtensions = { dup: ext('bpmn'), @@ -154,7 +154,7 @@ describe('bpmnlintValidator: findings', () => { { kind: 'moddle', name: 'second', pkg: 'descriptor object', reason: 'BpmnModdle rejects it (package with prefix already defined)', hint }, ]); const found = (await bpmnlintValidator(engine, resolved).validate!(xml, ctx(xml))) as ValidatorFinding[]; - expect(codes(found.filter((finding) => !finding.code.startsWith('W_LINT_')))).toEqual(['label-required Activity_Task', 'no-disconnected Activity_Task', 'no-implicit-end Activity_Task', 'no-implicit-start Activity_Task']); + expect(codes(found.filter((finding) => !finding.code.startsWith('W_LINT_')))).toEqual(['label-required task_task', 'no-disconnected task_task', 'no-implicit-end task_task', 'no-implicit-start task_task']); expect(found.filter((finding) => finding.code.startsWith('W_LINT_')).map((finding) => finding.message)).toEqual([ 'moddle extension dup (descriptor object) left out: BpmnModdle rejects it (package with prefix already defined); rules reading dup: properties may report findings that are not there', expect.stringMatching(/^moddle extension nope \(descriptor object\) left out: BpmnModdle rejects it \(.+\); rules reading nope: properties may report findings that are not there$/), @@ -198,21 +198,21 @@ describe('bpmnlintValidator: findings', () => { return { check: await checkDoc(await Doc.fromXml(xml)), found: (await lintValidator.validate!(xml, ctx(xml))) as ValidatorFinding[] }; }; // a model without an engine (no engine namespace): bpmn-cli's reader takes calledDecision as the decision link, bpmnlint's parse does not know it - const design = await model([...LINE, { op: 'add', kind: 'businessRuleTask', name: 'Decide', after: 'Activity_Check' }, { op: 'set', id: 'Activity_Decide', values: { calledDecision: 'risk' } }]); + const design = await model([...LINE, { op: 'add', kind: 'businessRuleTask', name: 'Decide', after: 'userTask_check' }, { op: 'set', id: 'businessRuleTask_decide', values: { calledDecision: 'risk' } }]); const linked = await run(design, { rules: { 'label-required': 'error' } }); expect(linked.check.importWarnings).toEqual([]); - expect(linked.found).toEqual([{ severity: 'error', code: 'import-warning', message: 'Import warning: unknown attribute ', element: 'Activity_Decide', key: 'import|Activity_Decide|unknown attribute ', hint: expect.stringMatching(/^bpmn-cli's decision link in a model without an engine \(an unprefixed calledDecision on a business rule task; bpmn-cli's reader keeps it\): keep it\./) }]); + expect(linked.found).toEqual([{ severity: 'error', code: 'import-warning', message: 'Import warning: unknown attribute ', element: 'businessRuleTask_decide', key: 'import|businessRuleTask_decide|unknown attribute ', hint: expect.stringMatching(/^bpmn-cli's decision link in a model without an engine \(an unprefixed calledDecision on a business rule task; bpmn-cli's reader keeps it\): keep it\./) }]); // a moddle extension that declares the attribute: bpmnlint reads it const descriptor = { name: 'DesignIq', prefix: 'diq', uri: 'http://example.org/diq', types: [{ name: 'DecisionLink', extends: ['bpmn:BusinessRuleTask'], properties: [{ name: 'calledDecision', isAttr: true, type: 'String' }] }] }; expect((await run(design, { rules: { 'label-required': 'error' }, moddleExtensions: { diq: descriptor } })).found).toEqual([]); // what bpmn-cli's reader reports itself is not listed again - const foo = (await model(LINE)).replace('']); expect(own.found).toEqual([]); // an unresolved reference only the lint parse sees (a camunda errorRef read through the camunda moddle extension) - const c7 = (await applyToXml((await newXml({ processName: 'Order', target: 'camunda7' })).xml, [...LINE, { op: 'add', kind: 'serviceTask', name: 'Call', after: 'Activity_Check' }])).xml - .replace('', '\n '); + const c7 = (await applyToXml((await newXml({ processName: 'Order', target: 'camunda7' })).xml, [...LINE, { op: 'add', kind: 'serviceTask', name: 'Call', after: 'userTask_check' }])).xml + .replace('', '\n '); const camunda = bpmnlintEngine().plugin.getDefaultLintConfig({ engine: 'c7' }).moddleExtensions!; const dangling = await run(c7, { rules: { 'label-required': 'error' }, moddleExtensions: camunda }); expect(dangling.found).toEqual([expect.objectContaining({ severity: 'error', code: 'import-warning', message: 'Import warning: unresolved reference ', element: 'Def_X', hint: expect.stringMatching(/^bpmnlint parses the file with the \.bpmnlintrc's moddle extensions only and reports this as an error; bpmnlint in CI fails on it too\./) })]); @@ -241,22 +241,22 @@ describe('covered lint warnings and the incoming / outgoing lists', () => { } // a rule that is off covers nothing const off = await run(noEnd, { 'no-implicit-end': 'off' }); - expect(codes(off.validation.warnings)).toContain('W_DEAD_END Activity_Check'); + expect(codes(off.validation.warnings)).toContain('W_DEAD_END userTask_check'); }); it('a file without incoming / outgoing lists: validate lists the list rules with a hint, a write leaves the false ones out', async () => { const xml = listless(await model(LINE)); const checked = await run(xml); const lists = lintOf(checked.validation.errors).filter((finding) => ['no-implicit-end', 'no-implicit-start', 'no-disconnected'].includes(finding.code)); - expect(codes(lists).sort()).toEqual(['no-disconnected Activity_Check', 'no-disconnected Event_Done', 'no-disconnected Event_Received', 'no-implicit-end Activity_Check', 'no-implicit-end Event_Received', 'no-implicit-start Activity_Check', 'no-implicit-start Event_Done']); + expect(codes(lists).sort()).toEqual(['no-disconnected endEvent_done', 'no-disconnected startEvent_received', 'no-disconnected userTask_check', 'no-implicit-end startEvent_received', 'no-implicit-end userTask_check', 'no-implicit-start endEvent_done', 'no-implicit-start userTask_check']); for (const finding of lists) expect(finding.hint).toMatch(/^The element has sequence flows the file does not list under incoming \/ outgoing, which no-/); const dir = repo({ extends: 'bpmnlint:recommended' }, { 'listless.bpmn': xml }); - const added = mutationReport(await mutateFile(join(dir, 'listless.bpmn'), [{ op: 'add', kind: 'userTask', name: 'Ship', after: 'Activity_Check' }], { dryRun: true })).warnings.added; + const added = mutationReport(await mutateFile(join(dir, 'listless.bpmn'), [{ op: 'add', kind: 'userTask', name: 'Ship', after: 'userTask_check' }], { dryRun: true })).warnings.added; expect(lintOf(added)).toEqual([]); // a node that really has no flow is reported (and covers W_DEAD_END) - const loose = mutationReport(await mutateFile(join(dir, 'listless.bpmn'), [{ op: 'add', kind: 'userTask', name: 'Loose', in: 'Process_Order' }], { dryRun: true })).warnings.added; - expect(codes(lintOf(loose))).toEqual(['no-disconnected Activity_Loose', 'no-implicit-end Activity_Loose', 'no-implicit-start Activity_Loose']); - expect(codes(loose)).not.toContain('W_DEAD_END Activity_Loose'); + const loose = mutationReport(await mutateFile(join(dir, 'listless.bpmn'), [{ op: 'add', kind: 'userTask', name: 'Loose', in: 'order' }], { dryRun: true })).warnings.added; + expect(codes(lintOf(loose))).toEqual(['no-disconnected userTask_loose', 'no-implicit-end userTask_loose', 'no-implicit-start userTask_loose']); + expect(codes(loose)).not.toContain('W_DEAD_END userTask_loose'); expect(lintOf(loose).find((finding) => finding.code === 'no-implicit-end')!.hint).toMatch(/^Continue the flow/); }); @@ -265,7 +265,7 @@ describe('covered lint warnings and the incoming / outgoing lists', () => { const file = join(dir, 'listless.bpmn'); expect((await checkFile(file)).validation.validators![0]!.counts).toEqual({ errors: 7, warnings: 0 }); const lists = 'incoming / outgoing lists the file does not keep'; - const renamed = mutationReport(await mutateFile(file, [{ op: 'set', id: 'Activity_Check', values: { name: 'Check order' } }], { dryRun: true, lintFrom: dir })); + const renamed = mutationReport(await mutateFile(file, [{ op: 'set', id: 'userTask_check', values: { name: 'Check order' } }], { dryRun: true, lintFrom: dir })); expect(renamed.validation.validators).toEqual([{ name: 'bpmnlint', detail: '.bpmnlintrc in .', blocks: false, errors: [], warnings: [], preexisting: [], resolved: [], counts: { errors: 7, warnings: 0 }, leftOut: [{ reason: lists, count: 7 }] }]); expect(renderMutation(renamed)).toContain('\nvalidator bpmnlint (.bpmnlintrc in .): 7 error(s), 0 warning(s) in the result (0 added, 0 resolved, 0 already in the file, 7 left out (incoming / outgoing lists the file does not keep; `bpmn validate ` lists them))\n'); // --summary counts the findings the file already had among the warnings, and names the left-out ones in a line of its own @@ -274,7 +274,7 @@ describe('covered lint warnings and the incoming / outgoing lists', () => { expect(summary.leftOut).toEqual([{ validator: 'bpmnlint', reason: lists, count: 7 }]); expect(renderSummary(summary, { dryRun: true }).split('\n')).toContain('validator bpmnlint: 7 left out (incoming / outgoing lists the file does not keep; `bpmn validate ` lists them)'); // a new element: its list-rule findings are left out too, none is added; validate on the written file gives the same totals - const added = await mutateFile(file, [{ op: 'add', kind: 'userTask', name: 'Ship', after: 'Activity_Check' }], { lintFrom: dir }); + const added = await mutateFile(file, [{ op: 'add', kind: 'userTask', name: 'Ship', after: 'userTask_check' }], { lintFrom: dir }); expect(lintOf(mutationReport(added).warnings.added)).toEqual([]); const after = added.validation.validators![0]!; const validate = (await checkFile(file)).validation.validators![0]!; @@ -282,22 +282,22 @@ describe('covered lint warnings and the incoming / outgoing lists', () => { expect(after.counts).toEqual({ errors: 10, warnings: 0 }); expect(after.leftOut).toEqual([{ reason: lists, count: 10 }]); // lintBlocks: they do not block, and the line of a blocking validator names them too - const blocking = mutationReport(await mutateFile(file, [{ op: 'set', id: 'Activity_Ship', values: { name: 'Ship order' } }], { dryRun: true, lintBlocks: true, lintFrom: dir })); + const blocking = mutationReport(await mutateFile(file, [{ op: 'set', id: 'userTask_ship', values: { name: 'Ship order' } }], { dryRun: true, lintBlocks: true, lintFrom: dir })); expect(renderMutation(blocking)).toContain('\nvalidator bpmnlint (.bpmnlintrc in .): 10 error(s), 0 warning(s) in the result (10 left out (incoming / outgoing lists the file does not keep; `bpmn validate ` lists them))\n'); // a file that keeps its lists: no leftOut, the line as before const kept = repo({ extends: 'bpmnlint:recommended' }, { 'lists.bpmn': await model(LINE) }); - const plain = mutationReport(await mutateFile(join(kept, 'lists.bpmn'), [{ op: 'set', id: 'Activity_Check', values: { name: 'Check order' } }], { dryRun: true, lintFrom: kept })); + const plain = mutationReport(await mutateFile(join(kept, 'lists.bpmn'), [{ op: 'set', id: 'userTask_check', values: { name: 'Check order' } }], { dryRun: true, lintFrom: kept })); expect(plain.validation.validators![0]).not.toHaveProperty('leftOut'); expect(renderMutation(plain)).toContain('\nvalidator bpmnlint (.bpmnlintrc in .): 0 error(s), 0 warning(s) in the result (0 added, 0 resolved, 0 already in the file)\n'); expect(mutationSummary(plain)).not.toHaveProperty('leftOut'); }); it('a write on a file without the lists that gives a real dead end or a loose node a flow resolves no bpmnlint finding it still has; W_DEAD_END is resolved as itself', async () => { - const dir = repo({ extends: 'bpmnlint:recommended' }, { 'loose.bpmn': listless(await model([...LINE, { op: 'add', kind: 'task', name: 'Loose', in: 'Process_Order' }])) }); + const dir = repo({ extends: 'bpmnlint:recommended' }, { 'loose.bpmn': listless(await model([...LINE, { op: 'add', kind: 'task', name: 'Loose', in: 'order' }])) }); const file = join(dir, 'loose.bpmn'); // the loose task: no-disconnected, no-implicit-end and no-implicit-start, real ones (it has no flow) - expect(codes(lintOf((await checkFile(file)).validation.errors)).filter((code) => code.endsWith('Activity_Loose'))).toEqual(['no-disconnected Activity_Loose', 'no-implicit-end Activity_Loose', 'no-implicit-start Activity_Loose']); - for (const ops of [[{ op: 'add', kind: 'end', name: 'Fin', after: 'Activity_Loose' }], [{ op: 'connect', source: 'Activity_Check', target: 'Activity_Loose' }]] as EditCommand[][]) { + expect(codes(lintOf((await checkFile(file)).validation.errors)).filter((code) => code.endsWith('task_loose'))).toEqual(['no-disconnected task_loose', 'no-implicit-end task_loose', 'no-implicit-start task_loose']); + for (const ops of [[{ op: 'add', kind: 'end', name: 'Fin', after: 'task_loose' }], [{ op: 'connect', source: 'userTask_check', target: 'task_loose' }]] as EditCommand[][]) { const mutation = mutationReport(await mutateFile(file, ops, { dryRun: true, lintFrom: dir })); const firstValidator = mutation.validation.validators![0]!; // bpmnlint in CI still reports them (the lists stay unwritten): left out, not resolved @@ -307,30 +307,30 @@ describe('covered lint warnings and the incoming / outgoing lists', () => { const kept = firstValidator.counts.errors + firstValidator.counts.warnings - firstValidator.warnings.length - firstValidator.leftOut![0]!.count; expect(renderMutation(mutation), ops[0]!.op).toContain(` in the result (${firstValidator.warnings.length} added, 0 resolved, ${kept} already in the file, ${firstValidator.leftOut![0]!.count} left out (`); } - const ended = mutationReport(await mutateFile(file, [{ op: 'add', kind: 'end', name: 'Fin', after: 'Activity_Loose' }], { dryRun: true, lintFrom: dir })); - expect(codes(ended.warnings.resolved)).toEqual(['W_DEAD_END Activity_Loose']); + const ended = mutationReport(await mutateFile(file, [{ op: 'add', kind: 'end', name: 'Fin', after: 'task_loose' }], { dryRun: true, lintFrom: dir })); + expect(codes(ended.warnings.resolved)).toEqual(['W_DEAD_END task_loose']); expect(ended.validation.validators![0]).toMatchObject({ counts: { errors: 12, warnings: 0 }, leftOut: [{ count: 11 }] }); - const connected = mutationReport(await mutateFile(file, [{ op: 'connect', source: 'Activity_Check', target: 'Activity_Loose' }], { dryRun: true, lintFrom: dir })); - expect(codes(connected.warnings.resolved)).toEqual(['W_UNREACHABLE Activity_Loose']); + const connected = mutationReport(await mutateFile(file, [{ op: 'connect', source: 'userTask_check', target: 'task_loose' }], { dryRun: true, lintFrom: dir })); + expect(codes(connected.warnings.resolved)).toEqual(['W_UNREACHABLE task_loose']); expect(connected.validation.validators![0]).toMatchObject({ counts: { errors: 10, warnings: 0 }, leftOut: [{ count: 9 }] }); }); it('validate folds a list rule by hint: a real dead end keeps its own line and the fixing command', async () => { - // Activity_Loose has an incoming flow and no outgoing one: a dead end among the findings of the missing lists - const xml = listless(await model([...LINE, { op: 'add', kind: 'task', name: 'Ship', after: 'Activity_Check' }, { op: 'add', kind: 'task', name: 'Loose', in: 'Process_Order' }, { op: 'connect', source: 'Activity_Ship', target: 'Activity_Loose' }])); + // task_loose has an incoming flow and no outgoing one: a dead end among the findings of the missing lists + const xml = listless(await model([...LINE, { op: 'add', kind: 'task', name: 'Ship', after: 'userTask_check' }, { op: 'add', kind: 'task', name: 'Loose', in: 'order' }, { op: 'connect', source: 'task_ship', target: 'task_loose' }])); const engine = bpmnlintEngine(); const lintValidator = bpmnlintValidator(engine, await resolveBpmnlintConfig(engine, { extends: 'bpmnlint:recommended' })); const lines = renderValidation(validationReport(await checkDoc(await Doc.fromXml(xml), { validators: [lintValidator] }))).split('\n'); - expect(lines).toContain('[bpmnlint error] no-implicit-end x3 [Event_Received, Activity_Check, Activity_Ship]: Element is an implicit end (The element has sequence flows the file does not list under incoming / outgoing, which no-implicit-end reads (bpmnlint in CI reports it too); bpmn-cli writes those lists only where the file keeps them.)'); - expect(lines.find((outputLine) => outputLine.startsWith('[bpmnlint error] no-implicit-end Activity_Loose: Element is an implicit end (Continue the flow (`bpmn add "" --after Activity_Loose`)'))).toBeDefined(); + expect(lines).toContain('[bpmnlint error] no-implicit-end x3 [startEvent_received, userTask_check, task_ship]: Element is an implicit end (The element has sequence flows the file does not list under incoming / outgoing, which no-implicit-end reads (bpmnlint in CI reports it too); bpmn-cli writes those lists only where the file keeps them.)'); + expect(lines.find((outputLine) => outputLine.startsWith('[bpmnlint error] no-implicit-end task_loose: Element is an implicit end (Continue the flow (`bpmn add "" --after task_loose`)'))).toBeDefined(); }); it('event-based-gateway counts the outgoing list: validate lists it with the hint, a write that draws two branches does not, one branch is reported in both', async () => { - const base = listless(await model([{ op: 'add', kind: 'start', name: 'Paid' }, { op: 'add', kind: 'end', name: 'Done', after: 'Event_Paid' }])); + const base = listless(await model([{ op: 'add', kind: 'start', name: 'Paid' }, { op: 'add', kind: 'end', name: 'Done', after: 'startEvent_paid' }])); const branches: EditCommand[] = [ - { op: 'add', kind: 'eventBasedGateway', after: 'Event_Paid' }, - { op: 'add', kind: 'catch:timer', name: 'Timeout', flow: 'Flow_AfterPaidToDone' }, - { op: 'add', kind: 'catch:message', name: 'Answer', after: 'Gateway_AfterPaid', to: 'Event_Done' }, + { op: 'add', kind: 'eventBasedGateway', after: 'startEvent_paid' }, + { op: 'add', kind: 'catch:timer', name: 'Timeout', flow: 'flow_afterPaidToDone' }, + { op: 'add', kind: 'catch:message', name: 'Answer', after: 'gateway_afterPaid', to: 'endEvent_done' }, ]; const dir = repo({ extends: 'bpmnlint:recommended' }, { 'paid.bpmn': base }); const file = join(dir, 'paid.bpmn'); @@ -340,10 +340,10 @@ describe('covered lint warnings and the incoming / outgoing lists', () => { } const two = await run((await mutateFile(file, branches, { dryRun: true })).xml); const gateway = lintOf(two.validation.errors).find((finding) => finding.code === 'event-based-gateway'); - expect(gateway).toMatchObject({ element: 'Gateway_AfterPaid', hint: expect.stringMatching(/^The element has sequence flows the file does not list under incoming \/ outgoing, which event-based-gateway reads/) }); + expect(gateway).toMatchObject({ element: 'gateway_afterPaid', hint: expect.stringMatching(/^The element has sequence flows the file does not list under incoming \/ outgoing, which event-based-gateway reads/) }); // one branch: a finding of the model, not of the lists const one = mutationReport(await mutateFile(file, branches.slice(0, 2), { dryRun: true })); - expect(codes(lintOf(one.warnings.added))).toContain('event-based-gateway Gateway_AfterPaid'); + expect(codes(lintOf(one.warnings.added))).toContain('event-based-gateway gateway_afterPaid'); const single = lintOf((await run((await mutateFile(file, branches.slice(0, 2), { dryRun: true })).xml)).validation.errors).find((finding) => finding.code === 'event-based-gateway'); expect(single?.hint).toBeUndefined(); }); diff --git a/test/adapters/out/files/bpmnlint.test.ts b/test/adapters/out/files/bpmnlint.test.ts index cf1f4628..c33093f2 100644 --- a/test/adapters/out/files/bpmnlint.test.ts +++ b/test/adapters/out/files/bpmnlint.test.ts @@ -44,7 +44,7 @@ describe('the .bpmnlintrc on disk: lookup and flags', () => { expect(gitRoot(top), 'the temporary directory lies in a git repository').toBeUndefined(); const marker = join(outside, 'RAN.txt'); const evil = { main: `require('fs').writeFileSync(${JSON.stringify(marker)}, 'ran'); module.exports = { configs: { recommended: { rules: {} } } };` }; - const xml = await model([...LINE, { op: 'add', kind: 'task', after: 'Activity_Check' }]); + const xml = await model([...LINE, { op: 'add', kind: 'task', after: 'userTask_check' }]); // a .bpmnlintrc and a plugin in a parent directory (~, /tmp): not looked at mkdirSync(join(outside, 'a', 'b'), { recursive: true }); writeFileSync(join(outside, '.bpmnlintrc'), JSON.stringify({ extends: ['bpmnlint:recommended', 'plugin:evil/recommended'] })); @@ -60,7 +60,7 @@ describe('the .bpmnlintrc on disk: lookup and flags', () => { writeFileSync(join(outside, 'x.bpmn'), xml); expect(findBpmnlintConfig(outside)).toBe(join(outside, '.bpmnlintrc')); const own = await checkFile(join(outside, 'x.bpmn'), { lintProjectCode: true, lintFrom: outside }); - expect(codes(lintOf(own.validation.errors))).toContain('label-required Activity_AfterCheck'); + expect(codes(lintOf(own.validation.errors))).toContain('label-required task_afterCheck'); expect(own.validation.warnings.filter((warning) => warning.code === 'W_LINT_UNAVAILABLE').map((warning) => `${warning.message} (${warning.hint})`)).toEqual([ 'plugin:evil/recommended left out: bpmnlint-plugin-evil is not bundled with bpmn-cli, and bpmn-cli runs project plugins only inside a git repository (the .bpmnlintrc in . is in none) (Keep the .bpmnlintrc and the plugin in a git repository, or remove the plugin from the configuration; --no-lint-config skips bpmnlint.)', ]); @@ -75,7 +75,7 @@ describe('the .bpmnlintrc on disk: lookup and flags', () => { // POSIX uids; as root every file belongs to root, which counts as one's own it.skipIf(!process.getuid || process.getuid() === 0)('a repository whose .git belongs to another user (a planted /tmp/.git): its .bpmnlintrc applies with the rules bpmn-cli ships, its project plugins do not run, W_LINT_UNAVAILABLE says why', async () => { const marker = join(top, 'RAN-foreign.txt'); - const xml = await model([...LINE, { op: 'add', kind: 'task', after: 'Activity_Check' }]); + const xml = await model([...LINE, { op: 'add', kind: 'task', after: 'userTask_check' }]); const dir = repo({ extends: ['bpmnlint:recommended', 'plugin:evil/recommended'] }, { 'x.bpmn': xml, 'models/y.bpmn': xml }); plugin(dir, 'bpmnlint-plugin-evil', { main: `require('fs').writeFileSync(${JSON.stringify(marker)}, 'ran'); module.exports = { configs: { recommended: { rules: {} } } };` }); const why = (from: string) => `plugin:evil/recommended left out: bpmnlint-plugin-evil is not bundled with bpmn-cli, and bpmn-cli runs project plugins only in a repository of the user running it (the repository of ${from} belongs to another user)`; @@ -86,7 +86,7 @@ describe('the .bpmnlintrc on disk: lookup and flags', () => { expect(findBpmnlintConfig(join(dir, 'models'))).toBe(join(dir, '.bpmnlintrc')); for (const [file, from, git] of [[join(dir, 'x.bpmn'), dir, '.git'], [join(dir, 'models', 'y.bpmn'), join(dir, 'models'), '../.git']] as const) { const checked = await checkFile(file, { lintProjectCode: true, lintFrom: from }); - expect(codes(lintOf(checked.validation.errors)), file).toContain('label-required Activity_AfterCheck'); + expect(codes(lintOf(checked.validation.errors)), file).toContain('label-required task_afterCheck'); expect(checked.validation.warnings.filter((warning) => warning.code === 'W_LINT_UNAVAILABLE').map((warning) => warning.message), file).toEqual([why(git)]); // git's safe.directory is not read: the hint does not say git refuses the repository expect(checked.validation.warnings.find((warning) => warning.code === 'W_LINT_UNAVAILABLE')?.hint).toBe("Check who created that repository and its .git; run bpmn-cli as its owner (git's safe.directory does not change this), or remove the plugin from the configuration; --no-lint-config skips bpmnlint."); @@ -128,7 +128,7 @@ describe('the .bpmnlintrc on disk: lookup and flags', () => { cases.push({ dir, marker: join(dir, 'RAN.txt'), counts }); } expect(cases.length).toBeGreaterThan(1); - const xml = await model([...LINE, { op: 'add', kind: 'task', after: 'Activity_Check' }]); + const xml = await model([...LINE, { op: 'add', kind: 'task', after: 'userTask_check' }]); const why = (git: string) => `plugin:evil/recommended left out: bpmnlint-plugin-evil is not bundled with bpmn-cli, and bpmn-cli runs project plugins only in a repository of the user running it (the repository of ${git} belongs to another user)`; const uid = vi.spyOn(process, 'getuid').mockReturnValue(424242); try { @@ -138,7 +138,7 @@ describe('the .bpmnlintrc on disk: lookup and flags', () => { expect(findBpmnlintConfig(join(dir, 'models')), dir).toBe(counts ? join(dir, '.bpmnlintrc') : undefined); const below = await checkFile(join(dir, 'models', 'y.bpmn'), { lintProjectCode: true, lintFrom: join(dir, 'models') }); if (counts) { - expect(codes(lintOf(below.validation.errors)), dir).toContain('label-required Activity_AfterCheck'); + expect(codes(lintOf(below.validation.errors)), dir).toContain('label-required task_afterCheck'); expect(below.validation.warnings.filter((warning) => warning.code === 'W_LINT_UNAVAILABLE').map((warning) => warning.message), dir).toEqual([why('../.git')]); } else { expect(below.validation.validators, dir).toBeUndefined(); @@ -146,7 +146,7 @@ describe('the .bpmnlintrc on disk: lookup and flags', () => { // the .bpmnlintrc next to the model: bundled rules only writeFileSync(join(dir, 'x.bpmn'), xml); const checked = await checkFile(join(dir, 'x.bpmn'), { lintProjectCode: true, lintFrom: dir }); - expect(codes(lintOf(checked.validation.errors)), dir).toContain('label-required Activity_AfterCheck'); + expect(codes(lintOf(checked.validation.errors)), dir).toContain('label-required task_afterCheck'); expect(checked.validation.warnings.filter((warning) => warning.code === 'W_LINT_UNAVAILABLE').map((warning) => warning.message), dir).toEqual([why('.git')]); expect(existsSync(marker), dir).toBe(false); } @@ -240,7 +240,7 @@ describe('the .bpmnlintrc on disk: lookup and flags', () => { writeFileSync(join(stale, '.git'), 'gitdir: ../missing.git\n'); writeFileSync(join(stale, '.bpmnlintrc'), '{}'); // a repository outside the home directory (C:\dev\proj), with the plugin installed - const dev = repo({ extends: ['bpmnlint:recommended', 'plugin:evil/recommended'] }, { 'models/z.bpmn': await model([...LINE, { op: 'add', kind: 'task', after: 'Activity_Check' }]) }, join(drive, 'dev')); + const dev = repo({ extends: ['bpmnlint:recommended', 'plugin:evil/recommended'] }, { 'models/z.bpmn': await model([...LINE, { op: 'add', kind: 'task', after: 'userTask_check' }]) }, join(drive, 'dev')); const devMarker = join(dev, 'RAN.txt'); plugin(dev, 'bpmnlint-plugin-evil', evil(devMarker)); const homeOff = (git: string) => `plugin:evil/recommended left out: bpmnlint-plugin-evil is not bundled with bpmn-cli, and bpmn-cli runs project plugins only in a repository of the user running it, and where it cannot read file owners (Windows) only in one inside the home directory (${git} is outside it) (Keep the repository inside your home directory, or remove the plugin from the configuration; --no-lint-config skips bpmnlint.)`; @@ -256,7 +256,7 @@ describe('the .bpmnlintrc on disk: lookup and flags', () => { // outside the home directory: the repository's .bpmnlintrc applies with bpmn-cli's rules, the plugin is left out and named expect(findBpmnlintConfig(join(dev, 'models'))).toBe(join(dev, '.bpmnlintrc')); const outside = await checkFile(join(dev, 'models', 'z.bpmn'), { lintProjectCode: true, lintFrom: join(dev, 'models') }); - expect(codes(lintOf(outside.validation.errors))).toContain('label-required Activity_AfterCheck'); + expect(codes(lintOf(outside.validation.errors))).toContain('label-required task_afterCheck'); expect(outside.validation.warnings.filter((warning) => warning.code === 'W_LINT_UNAVAILABLE').map((warning) => `${warning.message} (${warning.hint})`)).toEqual([homeOff('../.git')]); expect(existsSync(devMarker)).toBe(false); // inside the home directory: the parent .bpmnlintrc counts and the plugin runs @@ -274,7 +274,7 @@ describe('the .bpmnlintrc on disk: lookup and flags', () => { // physical paths, like the CLI's working directory (the temporary directory itself may be reached through a link) const base = join(realpathSync(top), 'links'); mkdirSync(base, { recursive: true }); - const xml = await model([...LINE, { op: 'add', kind: 'task', after: 'Activity_Check' }]); + const xml = await model([...LINE, { op: 'add', kind: 'task', after: 'userTask_check' }]); const dir = repo({ extends: 'bpmnlint:recommended' }, { 'models/x.bpmn': xml }, base); // outside/mlink -> repo/models: as given, outside lies in no repository and has no .bpmnlintrc; the physical directory is the repository's const outside = join(base, 'outside'); @@ -288,7 +288,7 @@ describe('the .bpmnlintrc on disk: lookup and flags', () => { expect((await checkFile(join(outside, 'flink.bpmn'), { lintFrom: outside })).validation.validators ?? []).toEqual([]); const linked = await checkFile(join(outside, 'mlink', 'x.bpmn'), { lintProjectCode: true, lintFrom: outside }); expect(linked.validation.validators?.[0]).toMatchObject({ name: 'bpmnlint', detail: `.bpmnlintrc in ../${basename(dir)}` }); - expect(codes(lintOf(linked.validation.errors))).toContain('label-required Activity_AfterCheck'); + expect(codes(lintOf(linked.validation.errors))).toContain('label-required task_afterCheck'); // stdin from the linked directory: the same lookup from lintFrom; the path as `..` resolves there expect(await projectBpmnlint(undefined, { lintFrom: join(outside, 'mlink') })).toMatchObject({ name: 'bpmnlint', detail: '.bpmnlintrc in ..' }); // the opposite: repo/extlink -> a directory of another repository; as given the walk finds this repository's .bpmnlintrc, which stays @@ -311,9 +311,9 @@ describe('the .bpmnlintrc on disk: lookup and flags', () => { expect(await projectBpmnlint(undefined, {})).toBeUndefined(); expect(await projectBpmnlint(undefined, { lintFrom: dir })).toMatchObject({ name: 'bpmnlint', detail: '.bpmnlintrc in .' }); // stdin (`validate -`): checkLoadedDoc - const doc = await Doc.fromXml(await model([...LINE, { op: 'add', kind: 'task', after: 'Activity_Check' }])); + const doc = await Doc.fromXml(await model([...LINE, { op: 'add', kind: 'task', after: 'userTask_check' }])); const checked = await checkLoadedDoc(doc, { lintFrom: dir }); - expect(codes(lintOf(checked.validation.errors))).toContain('label-required Activity_AfterCheck'); + expect(codes(lintOf(checked.validation.errors))).toContain('label-required task_afterCheck'); // a host's own validator named bpmnlint: the .bpmnlintrc's does not run as well expect(await projectBpmnlint(join(dir, 'x.bpmn'), { validators: [{ name: 'bpmnlint', validate: () => [] }] })).toBeUndefined(); }); @@ -372,12 +372,12 @@ describe('the resolver chain: bundled, shipped, project', () => { expect(Object.keys(loaded.resolved.moddleExtensions), spelling).toEqual(['camunda']); const found = lintOf((await checkFile(join(dir, 'x.bpmn'), { lintProjectCode: true })).validation.warnings); // historyTimeToLive is read as camunda:historyTimeToLive (no false finding) - expect(codes(found), spelling).not.toContain('camunda-compat/history-time-to-live Process_Order'); + expect(codes(found), spelling).not.toContain('camunda-compat/history-time-to-live order'); } // without the moddle extension the attribute is unknown, as bpmnlint in CI would see it const bare = repo({ extends: ['plugin:camunda-compat/camunda-platform-7-24'] }, { 'x.bpmn': xml }); const found = lintOf((await checkFile(join(bare, 'x.bpmn'))).validation.warnings); - expect(codes(found)).toContain('camunda-compat/history-time-to-live Process_Order'); + expect(codes(found)).toContain('camunda-compat/history-time-to-live order'); }); it('camunda-compat configs the bundle lacks come from the camunda-compat bpmn-cli ships (offline, no project code)', async () => { @@ -413,7 +413,7 @@ describe('the resolver chain: bundled, shipped, project', () => { }); it('a project plugin runs from the repository\'s node_modules (named in the detail); a config the project answers pins its package', async () => { - const dir = repo({ extends: ['bpmnlint:recommended', 'plugin:local/recommended', 'plugin:camunda-compat/camunda-cloud-99-0'], rules: { 'local/topic': ['info', { attr: 'topic' }] } }, { 'x.bpmn': await model([...LINE, { op: 'add', kind: 'serviceTask', name: 'Book', after: 'Activity_Check' }]) }); + const dir = repo({ extends: ['bpmnlint:recommended', 'plugin:local/recommended', 'plugin:camunda-compat/camunda-cloud-99-0'], rules: { 'local/topic': ['info', { attr: 'topic' }] } }, { 'x.bpmn': await model([...LINE, { op: 'add', kind: 'serviceTask', name: 'Book', after: 'userTask_check' }]) }); plugin(dir, 'bpmnlint-plugin-local', { configs: { recommended: { rules: { topic: 'error' } } }, rules: { topic: "module.exports = (o = {}) => ({ check(node, reporter) { if (node.$type === 'bpmn:ServiceTask') reporter.report(node.id, `Service task has no ${o.attr ?? 'camunda:topic'}`); } });" }, @@ -430,7 +430,7 @@ describe('the resolver chain: bundled, shipped, project', () => { expect(used.get('rule:bpmnlint/label-required')).toBe('bundled'); const found = lintOf((await checkFile(join(dir, 'x.bpmn'), { lintProjectCode: true, lintFrom: dir })).validation.warnings); // info -> warning, options from the .bpmnlintrc - expect(found.map((finding) => `${finding.code} ${finding.element}: ${finding.message}`)).toEqual(['local/topic Activity_Book: Service task has no topic', 'camunda-compat/history-time-to-live Process_Order: from the project']); + expect(found.map((finding) => `${finding.code} ${finding.element}: ${finding.message}`)).toEqual(['local/topic serviceTask_book: Service task has no topic', 'camunda-compat/history-time-to-live order: from the project']); // a host that does not run project code (the library default) const nocode = await chainOf(dir, false); expect(nocode.loaded.resolved.unavailable.map((unavailableEntry) => `${unavailableEntry.name}: ${unavailableEntry.reason}`)).toEqual([ @@ -461,7 +461,7 @@ describe('the resolver chain: bundled, shipped, project', () => { }); it('what cannot be resolved is named once per package and reason, with a hint; the rest runs', async () => { - const dir = repo({ extends: ['bpmnlint:recommended', 'plugin:missing/recommended'], rules: { 'missing/foo': 'error', 'nope-rule': 'warn', 'miragon/element-id-naming': 'warn' } }, { 'x.bpmn': await model([...LINE, { op: 'add', kind: 'task', after: 'Activity_Check' }]) }); + const dir = repo({ extends: ['bpmnlint:recommended', 'plugin:missing/recommended'], rules: { 'missing/foo': 'error', 'nope-rule': 'warn', 'miragon/element-id-naming': 'warn' } }, { 'x.bpmn': await model([...LINE, { op: 'add', kind: 'task', after: 'userTask_check' }]) }); const checked = await checkFile(join(dir, 'x.bpmn'), { lintProjectCode: true, lintFrom: dir }); const unavailable = checked.validation.warnings.filter((warning) => warning.code === 'W_LINT_UNAVAILABLE').map((warning) => `${warning.message} (${warning.hint})`); expect(unavailable).toEqual([ @@ -469,7 +469,7 @@ describe('the resolver chain: bundled, shipped, project', () => { 'nope-rule left out: bpmnlint 11.15.0 has no rule nope-rule (bpmn-cli runs bpmnlint 11.15.0; update bpmn-cli or remove the rule.)', 'miragon/element-id-naming left out: bpmnlint-plugin-miragon is not installed (looked up from .) (Did you mean @miragon/rules/element-id-naming? bpmn-cli bundles that rule.)', ]); - expect(codes(lintOf(checked.validation.errors))).toContain('label-required Activity_AfterCheck'); + expect(codes(lintOf(checked.validation.errors))).toContain('label-required task_afterCheck'); // no rule: validate's text never folds them, each keeps its hint const text = renderValidation(validationReport(checked)).split('\n'); expect(text.filter((outputLine) => outputLine.startsWith('[bpmnlint] W_LINT_UNAVAILABLE'))).toEqual(unavailable.map((unavailableLine) => `[bpmnlint] W_LINT_UNAVAILABLE: ${unavailableLine}`)); @@ -520,7 +520,7 @@ describe('a long-running host: the load cache follows what the .bpmnlintrc left // the hint's npm i -D: the plugin runs from the next call on plugin(dir, 'bpmnlint-plugin-local', { configs: { recommended: { rules: { always: 'warn' } } }, rules: { always: ALWAYS } }); expect(await left()).toEqual([expect.stringMatching(/^y: cannot be read/), expect.stringMatching(/^z: not installed/)]); - expect(codes(lintOf((await checkFile(join(dir, 'x.bpmn'), opts)).validation.warnings))).toContain('local/always Process_Order'); + expect(codes(lintOf((await checkFile(join(dir, 'x.bpmn'), opts)).validation.warnings))).toContain('local/always order'); // the descriptor fixed, the moddle package installed writeFileSync(join(dir, 'tools', 'y.json'), descriptor('y')); expect(await left()).toEqual([expect.stringMatching(/^z: not installed/)]); @@ -581,7 +581,7 @@ describe('a long-running host: the load cache follows what the .bpmnlintrc left moddle(dir); expect(await left()).toEqual([]); const checked = (await checkFile(join(dir, 'x.bpmn'), opts)).validation; - expect(codes(lintOf(checked.errors))).toContain('outer/always Process_Order'); + expect(codes(lintOf(checked.errors))).toContain('outer/always order'); }); it('a package or descriptor that appears while the load runs is seen by the next call (the state is taken before the load looks)', async () => { @@ -599,24 +599,19 @@ describe('a long-running host: the load cache follows what the .bpmnlintrc left describe('writes and validate with a .bpmnlintrc (in process)', () => { const RC = { extends: ['bpmnlint:recommended', 'plugin:@miragon/rules/recommended-for-automation'], rules: { 'no-overlapping-elements': 'off', 'missing/x': 'warn' } }; - it('a write lists the findings it added ([bpmnlint error] in text), never refuses for them, and counts the rest; the bytes differ from those without bpmnlint only in the ids element-id-naming declares', async () => { - const dir = repo(RC, { 'x.bpmn': await model(LINE) }); + it('a write lists the findings it added ([bpmnlint error] in text), never refuses for them, and counts the rest; the bytes are those without bpmnlint (both follow element-id-naming\'s defaults)', async () => { + const dir = repo(RC, { 'x.bpmn': (await model(LINE)).replaceAll('userTask_check', 'Activity_Check') }); const file = join(dir, 'x.bpmn'); const ops: EditCommand[] = [{ op: 'add', kind: 'task', after: 'Activity_Check' }]; const plain = await mutateFile(file, ops, { dryRun: true, lintConfig: false }); const mutated = await mutateFile(file, ops, { dryRun: true, lintFrom: dir }); - // recommended-for-automation turns element-id-naming on: the new task and the flows bpmn-cli names take its prefixes and case (#34) - const ids: Array<[string, string]> = [ - ['Activity_AfterCheck', 'task_afterCheck'], - ['Flow_CheckToAfterCheck', 'flow_checkToAfterCheck'], - ['Flow_AfterCheckToDone', 'flow_afterCheckToDone'], - ]; - expect(mutated.xml).toBe(ids.reduce((xml, [fromId, toId]) => xml.replaceAll(fromId, toId), plain.xml)); - expect(mutated.conventions).toEqual({ ids: ['task_afterCheck', 'flow_checkToAfterCheck', 'flow_afterCheckToDone'], source: '.bpmnlintrc in .' }); + // recommended-for-automation turns element-id-naming on with its defaults: the ids new ids take anyway (#34) + expect(mutated.xml).toBe(plain.xml); + expect(mutated.changes.created.map((createdElement) => createdElement.id)).toEqual(['task_afterCheck', 'flow_afterCheckToDone']); const report = mutationReport(mutated); - // no element-id-naming finding is added; the spliced flow's old one (Flow_CheckToDone) is resolved by its rename + // no element-id-naming finding is added expect(codes(lintOf(report.warnings.added))).toEqual(['label-required task_afterCheck']); - expect(codes(lintOf(report.warnings.resolved))).toEqual(['@miragon/rules/element-id-naming Flow_CheckToDone']); + expect(codes(lintOf(report.warnings.resolved))).toEqual([]); expect(lintOf(report.warnings.added)[0]).toMatchObject({ severity: 'error' }); // W_LINT_UNAVAILABLE is the same before and after: counted, not added expect(codes(report.warnings.added)).not.toContain('W_LINT_UNAVAILABLE'); @@ -626,7 +621,7 @@ describe('writes and validate with a .bpmnlintrc (in process)', () => { expect(text).toContain('warning [bpmnlint error] label-required task_afterCheck: Element is missing label/name\n'); const kept = validatorEntry!.counts.errors + validatorEntry!.counts.warnings - 1; expect(text).toContain(`\nvalidator bpmnlint (.bpmnlintrc in .; 1 left out (W_LINT_UNAVAILABLE; bpmn validate lists it)): ${validatorEntry!.counts.errors} error(s), ${validatorEntry!.counts.warnings} warning(s) in the result (1 added, ${validatorEntry!.resolved.length} resolved, ${kept} already in the file)\n`); - expect(text).toContain("\nconventions: task_afterCheck, flow_checkToAfterCheck, flow_afterCheckToDone follow element-id-naming (.bpmnlintrc in .), not the file's own id style (`bpmn show --conventions`)\n"); + expect(text).not.toContain('\nconventions:'); // --summary has no validator line: its count line counts the old findings (the library form of the CLI's --summary) const summary = renderSummary(mutationSummary(report), { advisory: advisoryValidators(report) }); expect(summary).toContain(`\nwarnings: ${report.warnings.added.length} added, ${report.warnings.resolved.length} resolved, ${report.warnings.preexistingCount + kept} already in the file\n`); @@ -638,22 +633,22 @@ describe('writes and validate with a .bpmnlintrc (in process)', () => { }); it('a moddle descriptor on disk BpmnModdle rejects is left out and counted, the rules run in validate and writes; with one it cannot parse with, a write says that no rule ran', async () => { - const loose = await model([...LINE, { op: 'add', kind: 'task', in: 'Process_Order' }]); + const loose = await model([...LINE, { op: 'add', kind: 'task', in: 'order' }]); const dup = { name: 'Dup', prefix: 'bpmn', uri: 'http://example.org/dup', types: [] }; const dir = repo({ extends: 'bpmnlint:recommended', moddleExtensions: { dup: './moddle/dup.json' } }, { 'moddle/dup.json': JSON.stringify(dup), 'x.bpmn': loose }); const file = join(dir, 'x.bpmn'); const report = validationReport(await checkFile(file, { lintFrom: dir })); - expect(codes(lintOf(report.errors))).toEqual(['label-required Activity_Task', 'no-disconnected Activity_Task', 'no-implicit-end Activity_Task', 'no-implicit-start Activity_Task']); + expect(codes(lintOf(report.errors))).toEqual(['label-required task_task', 'no-disconnected task_task', 'no-implicit-end task_task', 'no-implicit-start task_task']); const text = renderValidation(report); expect(text).toContain('\n[bpmnlint] W_LINT_UNAVAILABLE: moddle extension dup (./moddle/dup.json) left out: BpmnModdle rejects it (package with prefix already defined); rules reading dup: properties may report findings that are not there (Fix the descriptor, or remove the moddleExtensions entry; bpmnlint in CI fails on the whole file with it.)\n'); expect(text).toContain('\nvalidator bpmnlint (.bpmnlintrc in .; 1 left out (W_LINT_UNAVAILABLE; bpmn validate lists it)): 4 error(s), 1 warning(s) in the file\n'); - const named = mutationReport(await mutateFile(file, [{ op: 'set', id: 'Activity_Task', values: { name: 'Loose' } }], { dryRun: true, lintFrom: dir })); - expect(codes(lintOf(named.warnings.resolved))).toEqual(['label-required Activity_Task']); + const named = mutationReport(await mutateFile(file, [{ op: 'set', id: 'task_task', values: { name: 'Loose' } }], { dryRun: true, lintFrom: dir })); + expect(codes(lintOf(named.warnings.resolved))).toEqual(['label-required task_task']); expect(renderMutation(named)).toContain('\nvalidator bpmnlint (.bpmnlintrc in .; 1 left out (W_LINT_UNAVAILABLE; bpmn validate lists it)): 3 error(s), 1 warning(s) in the result (0 added, 1 resolved, 4 already in the file)\n'); const broken = repo({ extends: 'bpmnlint:recommended', moddleExtensions: { x: './x.json' } }, { 'x.json': JSON.stringify(REDEFINES_NOTHING), 'x.bpmn': loose }); expect(codes((await checkFile(join(broken, 'x.bpmn'), { lintFrom: broken })).validation.warnings)).toContain('W_LINT_FAILED'); - const failed = mutationReport(await mutateFile(join(broken, 'x.bpmn'), [{ op: 'set', id: 'Activity_Task', values: { name: 'Loose' } }], { dryRun: true, lintFrom: broken })); + const failed = mutationReport(await mutateFile(join(broken, 'x.bpmn'), [{ op: 'set', id: 'task_task', values: { name: 'Loose' } }], { dryRun: true, lintFrom: broken })); expect(failed.validation.validators![0]).toMatchObject({ resolved: [], counts: { errors: 0, warnings: 1 }, leftOut: [{ reason: 'W_LINT_FAILED, no rule ran', count: 1 }] }); expect(renderMutation(failed)).toContain('\nvalidator bpmnlint (.bpmnlintrc in .): 0 error(s), 1 warning(s) in the result (0 added, 0 resolved, 0 already in the file, 1 left out (W_LINT_FAILED, no rule ran; `bpmn validate ` lists it))\n'); // --summary says that no rule ran @@ -672,7 +667,7 @@ describe('writes and validate with a .bpmnlintrc (in process)', () => { it('a lint warning the change resolved that a bpmnlint finding covers is listed once, as that finding', async () => { const dir = repo({ extends: 'bpmnlint:recommended' }, { 'x.bpmn': (await newXml({ processName: 'Order' })).xml }); const mutation = mutationReport(await mutateFile(join(dir, 'x.bpmn'), [{ op: 'add', kind: 'start', name: 'Received' }], { dryRun: true })); - expect(codes(mutation.warnings.resolved)).toEqual(['start-event-required Process_Order']); + expect(codes(mutation.warnings.resolved)).toEqual(['start-event-required order']); }); it('`new` lists the findings of the new model; lintBlocks makes bpmnlint block a write like a validator, old errors do not', async () => { @@ -680,14 +675,14 @@ describe('writes and validate with a .bpmnlintrc (in process)', () => { const doc = Doc.create({ processName: 'Order' }, join(dir, 'new.bpmn')); const { mutateDocToFile } = await import('../../../../src/node.js'); const created = await mutateDocToFile(doc, [], { mustNotExist: true }); - expect(codes(lintOf(mutationReport(created).warnings.added))).toEqual(['end-event-required Process_Order', 'start-event-required Process_Order']); + expect(codes(lintOf(mutationReport(created).warnings.added))).toEqual(['end-event-required order', 'start-event-required order']); expect(created.validation.validators?.[0]?.counts).toEqual({ errors: 2, warnings: 0 }); // and validate fails on them (exit 2 in the CLI) expect((await checkFile(join(dir, 'new.bpmn'))).validation.errors.length).toBe(2); - const err = await mutateFile(join(dir, 'new.bpmn'), [{ op: 'add', kind: 'task', in: 'Process_Order' }], { lintBlocks: true }).then(() => undefined, (failure: BpmnError) => failure); + const err = await mutateFile(join(dir, 'new.bpmn'), [{ op: 'add', kind: 'task', in: 'order' }], { lintBlocks: true }).then(() => undefined, (failure: BpmnError) => failure); expect(err).toMatchObject({ code: 'E_VALIDATION' }); expect(String(err?.details.hint)).toContain("The project's bpmnlint blocks here because the host asked for it (lintBlocks)."); - const fine = await mutateFile(join(dir, 'new.bpmn'), [{ op: 'set', id: 'Process_Order', values: { name: 'Orders' } }], { lintBlocks: true, dryRun: true }); + const fine = await mutateFile(join(dir, 'new.bpmn'), [{ op: 'set', id: 'order', values: { name: 'Orders' } }], { lintBlocks: true, dryRun: true }); expect(fine.validation.validators?.[0]).toMatchObject({ errors: [], preexisting: [expect.objectContaining({ code: 'end-event-required' }), expect.objectContaining({ code: 'start-event-required' })] }); }); @@ -721,17 +716,17 @@ describe('writes and validate with a .bpmnlintrc (in process)', () => { describe('the id conventions of the .bpmnlintrc (#34, in process)', () => { const AUTOMATION = { extends: ['bpmnlint:recommended', 'plugin:@miragon/rules/recommended-for-automation'] }; - const ADD: EditCommand[] = [{ op: 'add', kind: 'serviceTask', name: 'Ship', after: 'Activity_Check' }]; + const ADD: EditCommand[] = [{ op: 'add', kind: 'serviceTask', name: 'Ship', after: 'userTask_check' }]; it('element-id-naming of the .bpmnlintrc decides the new ids; lintConfig false, a host\'s own conventions or its own bpmnlint validator switch the lookup off', async () => { - const dir = repo(AUTOMATION, { 'x.bpmn': await model(LINE) }); + const dir = repo({ ...AUTOMATION, rules: { [ID_NAMING_RULE]: ['warn', { case: 'PascalCase' }] } }, { 'x.bpmn': await model(LINE) }); const file = join(dir, 'x.bpmn'); const ids = async (opts: Parameters[2]) => (await mutateFile(file, ADD, { dryRun: true, lintFrom: dir, ...opts })).changes.created.map((createdElement) => createdElement.id); - expect(await ids({})).toEqual(['serviceTask_ship', 'flow_shipToDone']); - expect(await ids({ lintConfig: false })).toEqual(['Activity_Ship', 'Flow_ShipToDone']); - expect(await ids({ conventions: { prefixes: { 'bpmn:ServiceTask': 'svc_' }, case: 'PascalCase' } })).toEqual(['svc_Ship', 'Flow_ShipToDone']); + expect(await ids({})).toEqual(['serviceTask_Ship', 'flow_ShipToDone']); + expect(await ids({ lintConfig: false })).toEqual(['serviceTask_ship', 'flow_shipToDone']); + expect(await ids({ conventions: { prefixes: { 'bpmn:ServiceTask': 'svc_' }, case: 'PascalCase' } })).toEqual(['svc_Ship', 'sequenceFlow_ShipToDone']); const own = bpmnlintValidator(bpmnlintEngine(), await resolveBpmnlintConfig(bpmnlintEngine(), { extends: 'bpmnlint:recommended' })); - expect(await ids({ validators: [own] })).toEqual(['Activity_Ship', 'Flow_ShipToDone']); + expect(await ids({ validators: [own] })).toEqual(['serviceTask_ship', 'flow_shipToDone']); }); it('a value new ids cannot follow is one W_CONFIG_IGNORED of the validator: validate and `new` list it, a write only counts it', async () => { @@ -742,12 +737,12 @@ describe('the id conventions of the .bpmnlintrc (#34, in process)', () => { const checked = await checkFile(file, { lintFrom: dir }); expect(notes(checked.validation.warnings).map((note) => note.message)).toEqual([ '.bpmnlintrc in .: @miragon/rules/element-id-naming case "camelcase" is unknown: the rule checks PascalCase, and new ids take it', - ".bpmnlintrc in .: @miragon/rules/element-id-naming prefix of bpmn:UserTask \"user task_\" cannot start an id (a letter or _ first, then letters, digits, _ . -, at most 56 characters): new ids of the type follow the learned id style, and the rule reports them", + ".bpmnlintrc in .: @miragon/rules/element-id-naming prefix of bpmn:UserTask \"user task_\" cannot start an id (a letter or _ first, then letters, digits, _ . -, at most 56 characters): new ids of the type take bpmn-cli's own prefix, and the rule reports them", ]); - expect(renderValidation(validationReport(checked))).toMatch(/^\[bpmnlint\] W_CONFIG_IGNORED: \.bpmnlintrc in \.: @miragon\/rules\/element-id-naming case "camelcase" is unknown: .* {2}\(Write one of camelCase, PascalCase, snake_case, any in the \.bpmnlintrc\. Did you mean camelCase\?\)$/m); - const write = await mutateFile(file, [...ADD, { op: 'add', kind: 'userTask', name: 'Approve', after: 'Activity_Check' }], { dryRun: true, lintFrom: dir }); - // the user task's prefix is ignored: learned from the file's ids that do not follow the rule (Activity_Check) - expect(write.changes.created.map((createdElement) => createdElement.id)).toEqual(['serviceTask_Ship', 'flow_ShipToDone', 'Activity_Approve', 'flow_ApproveToShip']); + expect(renderValidation(validationReport(checked))).toMatch(/^\[bpmnlint\] W_CONFIG_IGNORED: \.bpmnlintrc in \.: @miragon\/rules\/element-id-naming case "camelcase" is unknown: .* {2}\(Write one of camelCase, PascalCase, snake_case, kebab-case, any in the \.bpmnlintrc\. Did you mean camelCase\?\)$/m); + const write = await mutateFile(file, [...ADD, { op: 'add', kind: 'userTask', name: 'Approve', after: 'userTask_check' }], { dryRun: true, lintFrom: dir }); + // the user task's prefix is ignored: bpmn-cli's own prefix, in the case the rule checks + expect(write.changes.created.map((createdElement) => createdElement.id)).toEqual(['serviceTask_Ship', 'flow_ShipToDone', 'userTask_Approve', 'flow_ApproveToShip']); const report = mutationReport(write); expect(notes(report.warnings.added)).toEqual([]); expect(renderMutation(report)).toContain('\nvalidator bpmnlint (.bpmnlintrc in .; 2 element-id-naming values ignored (W_CONFIG_IGNORED; bpmn validate lists them)): '); @@ -819,7 +814,7 @@ describe('the library examples (docs/library.md, docs/validators.md)', () => { const engine = bpmnlintEngine(); const resolved = await resolveBpmnlintConfig(engine, { extends: 'bpmnlint:recommended' }); const lint = bpmnlintValidator(engine, resolved, { detail: 'our rules' }); - const edit = await applyToXml(await model(LINE), [{ op: 'add', kind: 'task', after: 'Activity_Check' }], { validators: [lint] }); + const edit = await applyToXml(await model(LINE), [{ op: 'add', kind: 'task', after: 'userTask_check' }], { validators: [lint] }); expect(edit.result.warnings.added).toContainEqual(expect.objectContaining({ code: 'label-required', validator: 'bpmnlint', severity: 'error' })); // docs/validators.md: the plugin's default for an engine (a descriptor object as moddle extension), which turns element-id-naming on const validators = readFileSync(join(ROOT, 'docs', 'validators.md'), 'utf8'); @@ -857,16 +852,15 @@ describe('the library examples (docs/library.md, docs/validators.md)', () => { for (const line of [ "const automation = await resolveBpmnlintConfig(engine, engine.plugin.getDefaultLintConfig({ preset: 'automation' }));", "const { conventions, notes } = idConventionsFromLint(automation.rules[ID_NAMING_RULE], engine.plugin.naming, { source: 'our rules' });", - 'const named = await applyToXml(xml, ops, { validators: [bpmnlintValidator(engine, automation, { notes })], conventions });', + 'await applyToXml(xml, ops, { validators: [bpmnlintValidator(engine, automation, { notes })], conventions });', "await applyToXml(xml, ops, { conventions: { prefixes: { 'bpmn:ServiceTask': 'serviceTask_', 'bpmn:SequenceFlow': 'flow_' }, case: 'camelCase' } });", ]) expect(codeOf(library)).toContain(codeOf(line)); const engine = bpmnlintEngine(); const automation = await resolveBpmnlintConfig(engine, engine.plugin.getDefaultLintConfig({ preset: 'automation' })); const { conventions, notes } = idConventionsFromLint(automation.rules[ID_NAMING_RULE], engine.plugin.naming, { source: 'our rules' }); - const ops: EditCommand[] = [{ op: 'add', kind: 'serviceTask', name: 'Ship', after: 'Activity_Check' }]; + const ops: EditCommand[] = [{ op: 'add', kind: 'serviceTask', name: 'Ship', after: 'userTask_check' }]; const named = await applyToXml(await model(LINE), ops, { validators: [bpmnlintValidator(engine, automation, { notes })], conventions }); expect(named.result.created.map((createdElement) => createdElement.id)).toEqual(['serviceTask_ship', 'flow_shipToDone']); - expect(named.result.conventions).toEqual({ ids: ['serviceTask_ship', 'flow_checkToShip', 'flow_shipToDone'], source: 'our rules' }); expect(lintOf(named.result.warnings.added).filter((warning) => warning.code === ID_NAMING_RULE)).toEqual([]); const table = await applyToXml(await model(LINE), ops, { conventions: { prefixes: { 'bpmn:ServiceTask': 'serviceTask_', 'bpmn:SequenceFlow': 'flow_' }, case: 'camelCase' } }); expect(table.result.created.map((createdElement) => createdElement.id)).toEqual(['serviceTask_ship', 'flow_shipToDone']); diff --git a/test/adapters/out/render/render-agent.test.ts b/test/adapters/out/render/render-agent.test.ts index a01b7302..e107e9a8 100644 --- a/test/adapters/out/render/render-agent.test.ts +++ b/test/adapters/out/render/render-agent.test.ts @@ -289,9 +289,9 @@ describe('halos', () => { const doc = await Doc.fromXml(readFileSync(O2C, 'utf8')); const result = await mutateDoc(doc, parseOps([{ op: 'add', kind: 'userTask', name: 'Approve credit', after: 'Activity_CheckCredit' }, { op: 'color', ids: ['Activity_ShipGoods'], color: 'green' }]), { dryRun: true }); const highlight = changeHighlight(mutationReport(result)); - expect(highlight.created).toEqual(expect.arrayContaining(['Activity_ApproveCredit', 'Flow_ApproveCreditToCreditOk'])); + expect(highlight.created).toEqual(expect.arrayContaining(['userTask_approveCredit', 'flow_approveCreditToCreditOk'])); expect(highlight.changed).toEqual(expect.arrayContaining(['Flow_5', 'Activity_ShipGoods'])); - expect(highlight.changed).not.toContain('Activity_ApproveCredit'); + expect(highlight.changed).not.toContain('userTask_approveCredit'); expect(highlight.moved).toContain('Gateway_CreditOk'); for (const id of highlight.moved) expect([...highlight.created, ...highlight.changed]).not.toContain(id); }); @@ -431,11 +431,11 @@ describe('the commands', () => { const run = cli(['add', file, 'task', 'Check limit', '--after', 'Activity_CheckCredit', '--render', join(blocker, 'x.png')]); expect(run.code).toBe(0); expect(run.out).toMatch(/\nrender: failed E_IO: Cannot write /); - expect(readFileSync(file, 'utf8')).toContain('Activity_CheckLimit'); - const bad = cli(['add', file, 'task', 'Other', '--after', 'Activity_CheckLimit', '--render', join(dir, 'x.jpg')]); + expect(readFileSync(file, 'utf8')).toContain('task_checkLimit'); + const bad = cli(['add', file, 'task', 'Other', '--after', 'task_checkLimit', '--render', join(dir, 'x.jpg')]); expect(bad.code).toBe(1); expect(bad.err).toContain('expected a file name ending in .png or .svg'); - expect(readFileSync(file, 'utf8')).not.toContain('Activity_Other'); + expect(readFileSync(file, 'utf8')).not.toContain('task_other'); }); }); diff --git a/test/domain/batch/batch-aliases.test.ts b/test/domain/batch/batch-aliases.test.ts index ed07b862..e1d6e1e7 100644 --- a/test/domain/batch/batch-aliases.test.ts +++ b/test/domain/batch/batch-aliases.test.ts @@ -59,46 +59,46 @@ describe('batch aliases', () => { it('resolve in every id field, in set values and in format ops; the result lists alias -> final id', async () => { const outcome = await applyToXml((await newXml({ processName: 'Rechnungsprüfung' })).xml, INVOICE); expect(outcome.result.aliases).toEqual({ - $start: 'Event_RechnungEingegangen', - $check: 'Activity_VollstaendigkeitPruefen', - $ok: 'Gateway_Vollstaendig', - $yes: 'Flow_VollstaendigToBuchen', - $book: 'Activity_Buchen', - $no: 'Flow_VollstaendigToNachfordern', - $ask: 'Activity_Nachfordern', - $join: 'Gateway_Vollstaendig_join', - $late: 'Event_TimerOnNachfordern', - $lateFlow: 'Flow_TimerOnNachfordernToErinnert', + $start: 'startEvent_rechnungEingegangen', + $check: 'userTask_vollstaendigkeitPruefen', + $ok: 'gateway_vollstaendig', + $yes: 'flow_vollstaendigToBuchen', + $book: 'serviceTask_buchen', + $no: 'flow_vollstaendigToNachfordern', + $ask: 'userTask_nachfordern', + $join: 'gateway_vollstaendigJoin', + $late: 'event_timerOnNachfordern', + $lateFlow: 'flow_timerOnNachfordernToErinnert', }); - expect(outcome.xml).toContain(''); - expect(outcome.xml).toMatch(/\s*]*>\$\{vollstaendig\}<\/bpmn:conditionExpression>/); - expect(outcome.result.layout.format?.[0]).toMatchObject({ op: 'color', colored: ['Activity_VollstaendigkeitPruefen', 'Flow_VollstaendigToBuchen', 'Flow_TimerOnNachfordernToErinnert'] }); - expect(renderMutation(outcome.result)).toContain('aliases: $start = Event_RechnungEingegangen, $check = Activity_VollstaendigkeitPruefen, $ok = Gateway_Vollstaendig'); + expect(outcome.xml).toContain(''); + expect(outcome.xml).toMatch(/\s*]*>\$\{vollstaendig\}<\/bpmn:conditionExpression>/); + expect(outcome.result.layout.format?.[0]).toMatchObject({ op: 'color', colored: ['userTask_vollstaendigkeitPruefen', 'flow_vollstaendigToBuchen', 'flow_timerOnNachfordernToErinnert'] }); + expect(renderMutation(outcome.result)).toContain('aliases: $start = startEvent_rechnungEingegangen, $check = userTask_vollstaendigkeitPruefen, $ok = gateway_vollstaendig'); // without aliases the result has none - expect((await applyToXml(outcome.xml, [{ op: 'set', id: 'Activity_Buchen', values: { name: 'Buchen!' } }])).result.aliases).toBeUndefined(); + expect((await applyToXml(outcome.xml, [{ op: 'set', id: 'serviceTask_buchen', values: { name: 'Buchen!' } }])).result.aliases).toBeUndefined(); }); it('an alias names the element itself: it follows a rename later in the batch', async () => { const start = await applyToXml((await newXml({ processName: 'P' })).xml, [ { op: 'add', kind: 'start', name: 'Start' }, - { op: 'add', kind: 'end', name: 'End', after: 'Event_Start' }, + { op: 'add', kind: 'end', name: 'End', after: 'startEvent_start' }, ]); const outcome = await applyToXml(start.xml, [ - { op: 'add', kind: 'task', name: 'Work', after: 'Event_Start', as: '$work', flowAs: '$in' }, + { op: 'add', kind: 'task', name: 'Work', after: 'startEvent_start', as: '$work', flowAs: '$in' }, // splicing into $in renames it (its id named Start and Work); $in follows { op: 'add', kind: 'task', name: 'Prepare', flow: '$in' }, { op: 'set', id: '$in', values: { name: 'go' } }, { op: 'color', ids: ['$in'], color: 'blue' }, ]); - expect(outcome.result.aliases).toEqual({ $work: 'Activity_Work', $in: 'Flow_StartToPrepare' }); - expect(outcome.xml).toContain(''); - expect(outcome.result.layout.format?.[0]).toMatchObject({ colored: ['Flow_StartToPrepare'] }); + expect(outcome.result.aliases).toEqual({ $work: 'task_work', $in: 'flow_startToPrepare' }); + expect(outcome.xml).toContain(''); + expect(outcome.result.layout.format?.[0]).toMatchObject({ colored: ['flow_startToPrepare'] }); }); it('an alias follows its element through a retype to another type (a new element): later ops, format ops and the result use it', async () => { const xml = (await newXml({ processName: 'Invoice' })).xml; const outcome = await applyToXml(xml, [ - { op: 'add', kind: 'startEvent', name: 'Start', in: 'Process_Invoice', as: '$s' }, + { op: 'add', kind: 'startEvent', name: 'Start', in: 'invoice', as: '$s' }, { op: 'add', kind: 'serviceTask', name: 'Check invoice', after: '$s', as: '$t' }, { op: 'retype', id: '$t', kind: 'userTask' }, { op: 'set', id: '$t', values: { name: 'Check the invoice' } }, @@ -111,22 +111,22 @@ describe('batch aliases', () => { { op: 'add', kind: 'end', name: 'Done', after: '$paid' }, { op: 'color', ids: ['$t', '$ok'], color: 'green' }, ]); - expect(outcome.result.aliases).toEqual({ $s: 'Event_Start', $t: 'Activity_CheckInvoice', $ok: 'Gateway_Ok', $paid: 'Event_Paid' }); - expect(outcome.xml).toContain(''); - expect(outcome.xml).toContain(''); - expect(outcome.xml).toContain(''); - expect(outcome.result.layout.format?.[0]).toMatchObject({ op: 'color', colored: ['Activity_CheckInvoice', 'Gateway_Ok'] }); - expect(renderMutation(outcome.result)).toContain('aliases: $s = Event_Start, $t = Activity_CheckInvoice, $ok = Gateway_Ok, $paid = Event_Paid'); + expect(outcome.result.aliases).toEqual({ $s: 'startEvent_start', $t: 'manualTask_checkInvoice', $ok: 'gateway_ok', $paid: 'event_paid' }); + expect(outcome.xml).toContain(''); + expect(outcome.xml).toContain(''); + expect(outcome.xml).toContain(''); + expect(outcome.result.layout.format?.[0]).toMatchObject({ op: 'color', colored: ['manualTask_checkInvoice', 'gateway_ok'] }); + expect(renderMutation(outcome.result)).toContain('aliases: $s = startEvent_start, $t = manualTask_checkInvoice, $ok = gateway_ok, $paid = event_paid'); // removed after the retype: still E_NOT_FOUND, and left out of the result const removed = await rejection( applyToXml(xml, [ - { op: 'add', kind: 'task', name: 'T', in: 'Process_Invoice', as: '$t' }, + { op: 'add', kind: 'task', name: 'T', in: 'invoice', as: '$t' }, { op: 'retype', id: '$t', kind: 'userTask' }, { op: 'remove', ids: ['$t'] }, { op: 'set', id: '$t', values: { name: 'x' } }, ]), ); - expect([removed.code, removed.message]).toEqual(['E_NOT_FOUND', 'ops[3] (set): "id": $t named Activity_T, which an op before this one removed']); + expect([removed.code, removed.message]).toEqual(['E_NOT_FOUND', 'ops[3] (set): "id": $t named userTask_t, which an op before this one removed']); }); it('an alias follows a retype that gives the element a new id under the declared conventions (#34)', async () => { @@ -135,7 +135,7 @@ describe('batch aliases', () => { const outcome = await mutateDoc( doc, [ - { op: 'add', kind: 'startEvent', name: 'Start', in: 'Process_Invoice', as: '$s' }, + { op: 'add', kind: 'startEvent', name: 'Start', in: 'invoice', as: '$s' }, { op: 'add', kind: 'serviceTask', name: 'Check invoice', after: '$s', as: '$t' }, { op: 'retype', id: '$t', kind: 'userTask' }, { op: 'set', id: '$t', values: { name: 'Check the invoice' } }, @@ -152,33 +152,33 @@ describe('batch aliases', () => { it('resolve in the lane of split nodes and in the set map of add', async () => { const outcome = await applyToXml((await newXml({ processName: 'P' })).xml, [ { op: 'add', kind: 'participant', name: 'P' }, - { op: 'add', kind: 'lane', name: 'Clerk', in: 'Participant_P', as: '$clerk' }, - { op: 'add', kind: 'lane', name: 'Accounting', in: 'Participant_P', as: '$acc' }, - { op: 'add', kind: 'start', name: 'S', in: 'Process_P', lane: '$clerk', as: '$s' }, + { op: 'add', kind: 'lane', name: 'Clerk', in: 'participant_p', as: '$clerk' }, + { op: 'add', kind: 'lane', name: 'Accounting', in: 'participant_p', as: '$acc' }, + { op: 'add', kind: 'start', name: 'S', in: 'p', lane: '$clerk', as: '$s' }, { op: 'split', after: '$s', name: 'Ok?', as: '$ok', branches: [{ nodes: [{ kind: 'task', name: 'Book', lane: '$acc', set: { lane: '$acc' } }] }, { nodes: [{ kind: 'task', name: 'Ask', flowAs: '$ask' }] }] }, - { op: 'add', kind: 'task', name: 'Log', in: 'Process_P', set: { lane: '$acc' } }, + { op: 'add', kind: 'task', name: 'Log', in: 'p', set: { lane: '$acc' } }, { op: 'set', id: '$ok', values: { default: '$ask' } }, ]); const doc = await Doc.fromXml(outcome.xml); - expect(doc.lanesOf(doc.require('Activity_Book')).map((lane) => lane.get('id'))).toEqual(['Lane_Accounting']); - expect(doc.lanesOf(doc.require('Activity_Log')).map((lane) => lane.get('id'))).toEqual(['Lane_Accounting']); - expect(outcome.xml).toContain('default="Flow_OkToAsk"'); + expect(doc.lanesOf(doc.require('task_book')).map((lane) => lane.get('id'))).toEqual(['lane_accounting']); + expect(doc.lanesOf(doc.require('task_log')).map((lane) => lane.get('id'))).toEqual(['lane_accounting']); + expect(outcome.xml).toContain('default="flow_okToAsk"'); expect(() => parseOps([{ op: 'split', after: 'X', branches: [{ nodes: [{ kind: 'task', lane: '$nope' }] }] }])).toThrow(/"branches\[0\]\.nodes\[0\]\.lane": alias \$nope is not defined/); }); it('connect names the connection (not the message it creates); --if-absent binds the existing element', async () => { const base = await applyToXml((await newXml({ processName: 'Shop' })).xml, [ { op: 'add', kind: 'start', name: 'Order' }, - { op: 'add', kind: 'sendTask', name: 'Confirm', after: 'Event_Order' }, + { op: 'add', kind: 'sendTask', name: 'Confirm', after: 'startEvent_order' }, { op: 'add', kind: 'participant', name: 'Shop' }, { op: 'add', kind: 'participant', name: 'Customer', blackBox: true }, ]); const outcome = await applyToXml(base.xml, [ - { op: 'connect', source: 'Activity_Confirm', target: 'Participant_Customer', message: 'Confirmation', as: '$mf' }, - { op: 'add', kind: 'task', id: 'Activity_Confirm', ifAbsent: true, as: '$confirm' }, - { op: 'connect', source: '$confirm', target: 'Participant_Customer', ifAbsent: true, as: '$same' }, + { op: 'connect', source: 'sendTask_confirm', target: 'participant_customer', message: 'Confirmation', as: '$mf' }, + { op: 'add', kind: 'task', id: 'sendTask_confirm', ifAbsent: true, as: '$confirm' }, + { op: 'connect', source: '$confirm', target: 'participant_customer', ifAbsent: true, as: '$same' }, ]); - expect(outcome.result.aliases).toEqual({ $mf: 'Flow_ConfirmToCustomer', $confirm: 'Activity_Confirm', $same: 'Flow_ConfirmToCustomer' }); + expect(outcome.result.aliases).toEqual({ $mf: 'messageFlow_confirmToCustomer', $confirm: 'sendTask_confirm', $same: 'messageFlow_confirmToCustomer' }); }); it('is checked before anything runs: unknown (with the defined ones), defined later or twice, malformed, in an id field', () => { @@ -207,24 +207,24 @@ describe('batch aliases', () => { const bpmnError = await rejection(mutateDoc(doc, ops)); expect([bpmnError.code, bpmnError.details.op, bpmnError.details.candidates]).toEqual(['E_UNKNOWN_ALIAS', 1, ['$s']]); const removed = await rejection(mutateDoc(await Doc.fromXml((await newXml({ processName: 'P' })).xml), [{ op: 'add', kind: 'task', name: 'T', as: '$t' }, { op: 'remove', ids: ['$t'] }, { op: 'set', id: '$t', values: { name: 'x' } }] as EditCommand[])); - expect([removed.code, removed.message]).toEqual(['E_NOT_FOUND', 'ops[2] (set): "id": $t named Activity_T, which an op before this one removed']); + expect([removed.code, removed.message]).toEqual(['E_NOT_FOUND', 'ops[2] (set): "id": $t named task_t, which an op before this one removed']); }); it('joinAs without a join gateway is refused', async () => { - const xml = (await applyToXml((await newXml({ processName: 'P' })).xml, [{ op: 'add', kind: 'start', name: 'S' }, { op: 'add', kind: 'task', name: 'T', after: 'Event_S' }])).xml; - const bpmnError = await rejection(applyToXml(xml, [{ op: 'split', after: 'Activity_T', join: false, joinAs: '$j', branches: [{ nodes: [{ kind: 'end', name: 'A' }] }, { nodes: [{ kind: 'end', name: 'B' }] }] }])); - expect([bpmnError.code, bpmnError.message]).toEqual(['E_USAGE', 'joinAs $j: split Gateway_AfterT creates no join gateway (join is false)']); + const xml = (await applyToXml((await newXml({ processName: 'P' })).xml, [{ op: 'add', kind: 'start', name: 'S' }, { op: 'add', kind: 'task', name: 'T', after: 'startEvent_s' }])).xml; + const bpmnError = await rejection(applyToXml(xml, [{ op: 'split', after: 'task_t', join: false, joinAs: '$j', branches: [{ nodes: [{ kind: 'end', name: 'A' }] }, { nodes: [{ kind: 'end', name: 'B' }] }] }])); + expect([bpmnError.code, bpmnError.message]).toEqual(['E_USAGE', 'joinAs $j: split gateway_afterT creates no join gateway (join is false)']); }); it('the documented example (bpmn kinds --json -> opsExample) runs on the file it describes', async () => { const order = await applyToXml((await newXml({ processName: 'Order handling', target: 'camunda8' })).xml, [ { op: 'add', kind: 'start', name: 'Order received' }, - { op: 'add', kind: 'userTask', name: 'Check invoice', after: 'Event_OrderReceived' }, - { op: 'add', kind: 'end', name: 'Done', after: 'Activity_CheckInvoice' }, + { op: 'add', kind: 'userTask', name: 'Check invoice', after: 'startEvent_orderReceived' }, + { op: 'add', kind: 'end', name: 'Done', after: 'userTask_checkInvoice' }, ]); const outcome = await applyToXml(order.xml, opsExample()); - expect(outcome.result.aliases).toEqual({ $ok: 'Gateway_InvoiceOk', $book: 'Activity_BookInvoice', $clarify: 'Activity_ClarifyInvoice', $reminder: 'Event_Reminder', $remind: 'Activity_RemindCustomer', $sent: 'Event_ReminderSent' }); - expect(outcome.result.created.map((change) => change.id)).toContain('Flow_RemindCustomerToReminderSent'); + expect(outcome.result.aliases).toEqual({ $ok: 'gateway_invoiceOk', $book: 'serviceTask_bookInvoice', $clarify: 'userTask_clarifyInvoice', $reminder: 'event_reminder', $remind: 'sendTask_remindCustomer', $sent: 'endEvent_reminderSent' }); + expect(outcome.result.created.map((change) => change.id)).toContain('flow_remindCustomerToReminderSent'); expect(outcome.xml).toContain(''); }); @@ -237,20 +237,20 @@ describe('batch aliases', () => { }); }); -/** start -> Check -> Ok, made by bpmn-cli (Flow_CheckToOk names its ends). */ +/** start -> Check -> Ok, made by bpmn-cli (flow_checkToOk names its ends). */ async function checkToOk(): Promise { return ( await applyToXml((await newXml({ processName: 'Audit' })).xml, [ { op: 'add', kind: 'start', name: 'Start' }, - { op: 'add', kind: 'userTask', name: 'Check', after: 'Event_Start' }, - { op: 'add', kind: 'end', name: 'Ok', after: 'Activity_Check' }, + { op: 'add', kind: 'userTask', name: 'Check', after: 'startEvent_start' }, + { op: 'add', kind: 'end', name: 'Ok', after: 'userTask_check' }, ]) ).xml; } /** A gateway spliced into the flow to Ok, its new branch to Rejected the default: what a one-shot batch builds. */ const SPLICE_GATEWAY: unknown[] = [ - { op: 'add', kind: 'exclusiveGateway', name: 'Valid?', flow: 'Flow_CheckToOk', as: '$valid', flowAs: '$in', outAs: '$yes' }, + { op: 'add', kind: 'exclusiveGateway', name: 'Valid?', flow: 'flow_checkToOk', as: '$valid', flowAs: '$in', outAs: '$yes' }, { op: 'add', kind: 'end', name: 'Rejected', after: '$valid', flowAs: '$no' }, { op: 'set', id: '$yes', values: { name: 'yes', condition: '${valid}' } }, { op: 'set', id: '$no', values: { name: 'no' } }, @@ -261,75 +261,75 @@ describe('outAs: the flow an add creates out of the new node', () => { it('names the flow a splice creates (new node -> old successor): the same batch gives it a condition', async () => { const outcome = await applyToXml(await checkToOk(), SPLICE_GATEWAY); // flowAs is the flow the file had (renamed after its new ends), outAs the one the splice created - expect(outcome.result.aliases).toEqual({ $in: 'Flow_CheckToValid', $yes: 'Flow_ValidToOk', $valid: 'Gateway_Valid', $no: 'Flow_ValidToRejected' }); - expect(outcome.xml).toMatch(/\s*]*>\$\{valid\}<\/bpmn:conditionExpression>/); - expect(outcome.xml).toContain(''); - expect(outcome.xml).toContain(''); - expect(renderMutation(outcome.result)).toContain('aliases: $in = Flow_CheckToValid, $yes = Flow_ValidToOk, $valid = Gateway_Valid, $no = Flow_ValidToRejected'); + expect(outcome.result.aliases).toEqual({ $in: 'flow_checkToValid', $yes: 'flow_validToOk', $valid: 'gateway_valid', $no: 'flow_validToRejected' }); + expect(outcome.xml).toMatch(/\s*]*>\$\{valid\}<\/bpmn:conditionExpression>/); + expect(outcome.xml).toContain(''); + expect(outcome.xml).toContain(''); + expect(renderMutation(outcome.result)).toContain('aliases: $in = flow_checkToValid, $yes = flow_validToOk, $valid = gateway_valid, $no = flow_validToRejected'); // the other splices: after a node with one outgoing flow, before a node with one incoming flow - const after = await applyToXml(await checkToOk(), [{ op: 'add', kind: 'task', name: 'Log', after: 'Activity_Check', outAs: '$o' }]); - expect(after.result.aliases).toEqual({ $o: 'Flow_LogToOk' }); - const before = await applyToXml(await checkToOk(), [{ op: 'add', kind: 'task', name: 'Log', before: 'Activity_Check', outAs: '$o' }]); - expect(before.result.aliases).toEqual({ $o: 'Flow_LogToCheck' }); + const after = await applyToXml(await checkToOk(), [{ op: 'add', kind: 'task', name: 'Log', after: 'userTask_check', outAs: '$o' }]); + expect(after.result.aliases).toEqual({ $o: 'flow_logToOk' }); + const before = await applyToXml(await checkToOk(), [{ op: 'add', kind: 'task', name: 'Log', before: 'userTask_check', outAs: '$o' }]); + expect(before.result.aliases).toEqual({ $o: 'flow_logToCheck' }); }); it('names the flow to "to" and the flow of a prepend, follows a later rename, and binds an existing node\'s with --if-absent', async () => { const xml = (await applyToXml(await checkToOk(), SPLICE_GATEWAY)).xml; const outcome = await applyToXml(xml, [ // a branch that re-joins: appended after the gateway, connected to "to" - { op: 'add', kind: 'task', name: 'Escalate', after: 'Gateway_Valid', to: 'Event_Ok', condition: '${late}', outAs: '$back' }, + { op: 'add', kind: 'task', name: 'Escalate', after: 'gateway_valid', to: 'endEvent_ok', condition: '${late}', outAs: '$back' }, // unconnected with "to", and a boundary event with "to" - { op: 'add', kind: 'task', name: 'Lone', in: 'Process_Audit', to: 'Event_Rejected', as: '$lone', outAs: '$loneOut' }, - { op: 'add', kind: 'boundaryEvent:timer', on: 'Activity_Check', timer: 'PT1H', to: 'Event_Rejected', outAs: '$timeout' }, + { op: 'add', kind: 'task', name: 'Lone', in: 'audit', to: 'endEvent_rejected', as: '$lone', outAs: '$loneOut' }, + { op: 'add', kind: 'boundaryEvent:timer', on: 'userTask_check', timer: 'PT1H', to: 'endEvent_rejected', outAs: '$timeout' }, // a prepend: flowAs and outAs both name the one flow it creates (node -> anchor) { op: 'add', kind: 'task', name: 'Prepare', before: '$lone', flowAs: '$f', outAs: '$o' }, // the flow named after its ends follows a later splice into it, and so does its alias { op: 'add', kind: 'task', name: 'Recheck', flow: '$back', outAs: '$recheck' }, { op: 'set', id: '$back', values: { name: 'back' } }, - { op: 'add', kind: 'userTask', id: 'Activity_Check', ifAbsent: true, after: 'Event_Start', outAs: '$checkOut' }, + { op: 'add', kind: 'userTask', id: 'userTask_check', ifAbsent: true, after: 'startEvent_start', outAs: '$checkOut' }, ]); expect(outcome.result.aliases).toEqual({ - $back: 'Flow_EscalateToRecheck', - $loneOut: 'Flow_LoneToRejected', - $lone: 'Activity_Lone', - $timeout: 'Flow_TimerOnCheckToRejected', - $f: 'Flow_PrepareToLone', - $o: 'Flow_PrepareToLone', - $recheck: 'Flow_RecheckToOk', - $checkOut: 'Flow_CheckToValid', + $back: 'flow_escalateToRecheck', + $loneOut: 'flow_loneToRejected', + $lone: 'task_lone', + $timeout: 'flow_timerOnCheckToRejected', + $f: 'flow_prepareToLone', + $o: 'flow_prepareToLone', + $recheck: 'flow_recheckToOk', + $checkOut: 'flow_checkToValid', }); - expect(outcome.xml).toContain(''); - expect(outcome.xml).toContain(''); - expect(outcome.xml).toContain(''); + expect(outcome.xml).toContain(''); + expect(outcome.xml).toContain(''); + expect(outcome.xml).toContain(''); }); it('is refused where the add creates no flow out of the node or two (E_USAGE), before anything runs where the keys tell', async () => { const xml = (await applyToXml(await checkToOk(), SPLICE_GATEWAY)).xml; - const parse = failure(() => parseOps([{ op: 'add', kind: 'task', name: 'T', in: 'Process_Audit', outAs: '$o' }])); + const parse = failure(() => parseOps([{ op: 'add', kind: 'task', name: 'T', in: 'audit', outAs: '$o' }])); expect([parse.code, parse.message]).toEqual(['E_USAGE', 'ops[0] (add): "outAs" names the flow out of the node and needs "after", "before", "flow" or "to"']); - expect(failure(() => parseOps([{ op: 'add', kind: 'boundaryEvent:timer', on: 'Activity_Check', timer: 'PT1H', outAs: '$o' }])).message).toMatch(/"outAs" names the flow out of the node/); + expect(failure(() => parseOps([{ op: 'add', kind: 'boundaryEvent:timer', on: 'userTask_check', timer: 'PT1H', outAs: '$o' }])).message).toMatch(/"outAs" names the flow out of the node/); // an append: the new node ends its path - const append = await rejection(applyToXml(xml, [{ op: 'add', kind: 'task', name: 'X', after: 'Gateway_Valid', outAs: '$o' }])); - expect([append.code, append.message, append.details.op]).toEqual(['E_USAGE', 'outAs $o: Activity_X gets no flow out of it from this placement (--after Gateway_Valid appends it)', 0]); + const append = await rejection(applyToXml(xml, [{ op: 'add', kind: 'task', name: 'X', after: 'gateway_valid', outAs: '$o' }])); + expect([append.code, append.message, append.details.op]).toEqual(['E_USAGE', 'outAs $o: task_x gets no flow out of it from this placement (--after gateway_valid appends it)', 0]); expect(append.details.hint).toBe('outAs names the flow an add creates out of the new node: a splice, a prepend or "to" creates one. Otherwise connect the node with a connect op that has "as".'); // a splice with "to": two flows leave the node - const two = await rejection(applyToXml(xml, [{ op: 'add', kind: 'task', name: 'X', after: 'Activity_Check', to: 'Event_Rejected', outAs: '$o' }])); - expect([two.code, two.message]).toEqual(['E_USAGE', 'outAs $o: Activity_X gets 2 flows out of it from this placement (Flow_XToValid to Gateway_Valid, Flow_XToRejected to Event_Rejected)']); - expect(two.details.hint).toBe('outAs names one flow: drop "to" and connect Activity_X to Event_Rejected with a connect op that has "as" (outAs then names the other flow).'); + const two = await rejection(applyToXml(xml, [{ op: 'add', kind: 'task', name: 'X', after: 'userTask_check', to: 'endEvent_rejected', outAs: '$o' }])); + expect([two.code, two.message]).toEqual(['E_USAGE', 'outAs $o: task_x gets 2 flows out of it from this placement (flow_xToValid to gateway_valid, flow_xToRejected to endEvent_rejected)']); + expect(two.details.hint).toBe('outAs names one flow: drop "to" and connect task_x to endEvent_rejected with a connect op that has "as" (outAs then names the other flow).'); // no flow node: "to" makes an association, no sequence flow; parseOps knows the kind, so "to" is not suggested - const data = await rejection(applyToXml(xml, [{ op: 'add', kind: 'dataObject', name: 'D', in: 'Process_Audit', to: 'Activity_Check', outAs: '$d' }])); + const data = await rejection(applyToXml(xml, [{ op: 'add', kind: 'dataObject', name: 'D', in: 'audit', to: 'userTask_check', outAs: '$d' }])); expect([data.code, data.message]).toEqual(['E_USAGE', 'ops[0] (add): "outAs": add dataObject creates no sequence flow out of it']); expect(data.details.hint).toBe('Drop "outAs"; name a connection with a connect op that has "as".'); for (const kind of ['dataObject', 'textAnnotation', 'lane', 'participant']) { - expect(failure(() => parseOps([{ op: 'add', kind, name: 'D', in: 'Process_Audit', outAs: '$d' }])).message).toBe(`ops[0] (add): "outAs": add ${kind} creates no sequence flow out of it`); + expect(failure(() => parseOps([{ op: 'add', kind, name: 'D', in: 'audit', outAs: '$d' }])).message).toBe(`ops[0] (add): "outAs": add ${kind} creates no sequence flow out of it`); } - const typedData = await rejection(mutateDoc(await Doc.fromXml(xml), [{ op: 'add', kind: 'dataObject', name: 'D', in: 'Process_Audit', to: 'Activity_Check', outAs: '$d' }] as EditCommand[], { layout: false })); + const typedData = await rejection(mutateDoc(await Doc.fromXml(xml), [{ op: 'add', kind: 'dataObject', name: 'D', in: 'audit', to: 'userTask_check', outAs: '$d' }] as EditCommand[], { layout: false })); expect([typedData.code, typedData.message]).toEqual(['E_USAGE', 'outAs $d: add dataObject creates no sequence flow out of it']); // split nodes take no outAs (the split creates the flow out of a node with the next one) expect(failure(() => parseOps([{ op: 'split', after: 'X', branches: [{ nodes: [{ kind: 'task', outAs: '$o' }] }] }])).message).toMatch(/nodes\[0\]: unknown key "outAs"/); // typed ops of the library skip parseOps: the add refuses it while it runs - const typed = await rejection(mutateDoc(await Doc.fromXml(xml), [{ op: 'add', kind: 'task', name: 'T', in: 'Process_Audit', outAs: '$o' }] as EditCommand[], { layout: false })); - expect([typed.code, typed.message]).toEqual(['E_USAGE', 'outAs $o: Activity_T gets no flow out of it from this placement (--in)']); + const typed = await rejection(mutateDoc(await Doc.fromXml(xml), [{ op: 'add', kind: 'task', name: 'T', in: 'audit', outAs: '$o' }] as EditCommand[], { layout: false })); + expect([typed.code, typed.message]).toEqual(['E_USAGE', 'outAs $o: task_t gets no flow out of it from this placement (--in)']); // an alias like any other: used before its op, the message says which op defines it const early = failure(() => parseOps([{ op: 'set', id: '$o', values: { name: 'x' } }, { op: 'add', kind: 'task', name: 'T', after: 'A', outAs: '$o' }])); expect(early.message).toBe('ops[0] (set): "id": alias $o is not defined before this op (ops[1] defines it); no op before it defines one'); @@ -339,30 +339,30 @@ describe('outAs: the flow an add creates out of the new node', () => { it('with --if-absent binds an existing node\'s flow only when exactly one leaves it: a re-run never puts the condition on another branch', async () => { // the one-shot batch made idempotent: the gateway's add skips when it exists const batch = [ - { op: 'add', kind: 'exclusiveGateway', id: 'Gateway_Valid', name: 'Valid?', ifAbsent: true, flow: 'Flow_CheckToOk', as: '$valid', outAs: '$yes' }, + { op: 'add', kind: 'exclusiveGateway', id: 'gateway_valid', name: 'Valid?', ifAbsent: true, flow: 'flow_checkToOk', as: '$valid', outAs: '$yes' }, { op: 'set', id: '$yes', values: { name: 'yes', condition: '${valid}' } }, ]; const first = await applyToXml(await checkToOk(), batch); - expect(first.result.aliases).toEqual({ $valid: 'Gateway_Valid', $yes: 'Flow_ValidToOk' }); + expect(first.result.aliases).toEqual({ $valid: 'gateway_valid', $yes: 'flow_validToOk' }); // the same batch again while the gateway still has one flow out of it binds that one - expect((await applyToXml(first.xml, batch)).result.aliases).toEqual({ $valid: 'Gateway_Valid', $yes: 'Flow_ValidToOk' }); + expect((await applyToXml(first.xml, batch)).result.aliases).toEqual({ $valid: 'gateway_valid', $yes: 'flow_validToOk' }); // a second branch, put first: the gateway's first out-flow is now the other branch const branched = ( await applyToXml(first.xml, [ - { op: 'add', kind: 'end', name: 'Rejected', after: 'Gateway_Valid' }, - { op: 'order', id: 'Gateway_Valid', flows: ['Flow_ValidToRejected', 'Flow_ValidToOk'] }, + { op: 'add', kind: 'end', name: 'Rejected', after: 'gateway_valid' }, + { op: 'order', id: 'gateway_valid', flows: ['flow_validToRejected', 'flow_validToOk'] }, ]) ).xml; const again = await rejection(applyToXml(branched, batch)); - expect([again.code, again.message, again.details.op]).toEqual(['E_USAGE', 'outAs $yes: Gateway_Valid already exists and has 2 flows out of it (Flow_ValidToRejected, Flow_ValidToOk)', 0]); - expect(again.details.candidates).toEqual(['Flow_ValidToRejected', 'Flow_ValidToOk']); + expect([again.code, again.message, again.details.op]).toEqual(['E_USAGE', 'outAs $yes: gateway_valid already exists and has 2 flows out of it (flow_validToRejected, flow_validToOk)', 0]); + expect(again.details.candidates).toEqual(['flow_validToRejected', 'flow_validToOk']); expect(again.details.hint).toBe('With ifAbsent, outAs binds the flow out of an existing node only when exactly one leaves it: drop "outAs" there and use the flow\'s id.'); // no flow out of it, or no flow node: E_USAGE as on a first run, not an E_UNKNOWN_ALIAS later - const none = await rejection(applyToXml(branched, [{ op: 'add', kind: 'end', id: 'Event_Ok', ifAbsent: true, before: 'Activity_Check', outAs: '$o' }, { op: 'set', id: '$o', values: { name: 'x' } }])); - expect([none.code, none.message]).toEqual(['E_USAGE', 'outAs $o: Event_Ok already exists and has no flow out of it']); + const none = await rejection(applyToXml(branched, [{ op: 'add', kind: 'end', id: 'endEvent_ok', ifAbsent: true, before: 'userTask_check', outAs: '$o' }, { op: 'set', id: '$o', values: { name: 'x' } }])); + expect([none.code, none.message]).toEqual(['E_USAGE', 'outAs $o: endEvent_ok already exists and has no flow out of it']); const pool = (await applyToXml(branched, [{ op: 'add', kind: 'participant', name: 'Audit' }])).xml; - const notFlowNode = await rejection(applyToXml(pool, [{ op: 'add', kind: 'task', id: 'Participant_Audit', ifAbsent: true, after: 'Activity_Check', outAs: '$o' }])); - expect([notFlowNode.code, notFlowNode.message]).toEqual(['E_USAGE', 'outAs $o: Participant_Audit already exists and is a participant, no flow node']); + const notFlowNode = await rejection(applyToXml(pool, [{ op: 'add', kind: 'task', id: 'participant_audit', ifAbsent: true, after: 'userTask_check', outAs: '$o' }])); + expect([notFlowNode.code, notFlowNode.message]).toEqual(['E_USAGE', 'outAs $o: participant_audit already exists and is a participant, no flow node']); }); it('is in the schema, the full guide and the catalogue; the short guide leaves it out', () => { @@ -389,34 +389,34 @@ describe('batch aliases on the command line (`bpmn apply`)', () => { writeFileSync(file, (await newXml({ processName: 'Rechnungsprüfung' })).xml); writeFileSync(join(dir, 'ops.json'), JSON.stringify({ ops: INVOICE })); const text = execFileSync('node', [bin, 'apply', file, join(dir, 'ops.json')], { encoding: 'utf8' }); - expect(text).toMatch(/^aliases: \$start = Event_RechnungEingegangen, .*\$lateFlow = Flow_TimerOnNachfordernToErinnert$/m); - const json = JSON.parse(execFileSync('node', [bin, 'apply', file, '-', '--json'], { encoding: 'utf8', input: JSON.stringify([{ op: 'add', kind: 'task', name: 'Archivieren', after: 'Activity_Buchen', as: '$archive' }]) })); - expect(json.aliases).toEqual({ $archive: 'Activity_Archivieren' }); - expect(readFileSync(file, 'utf8')).toContain('id="Activity_Archivieren"'); + expect(text).toMatch(/^aliases: \$start = startEvent_rechnungEingegangen, .*\$lateFlow = flow_timerOnNachfordernToErinnert$/m); + const json = JSON.parse(execFileSync('node', [bin, 'apply', file, '-', '--json'], { encoding: 'utf8', input: JSON.stringify([{ op: 'add', kind: 'task', name: 'Archivieren', after: 'serviceTask_buchen', as: '$archive' }]) })); + expect(json.aliases).toEqual({ $archive: 'task_archivieren' }); + expect(readFileSync(file, 'utf8')).toContain('id="task_archivieren"'); }); it('lists the alias of an element retyped to another type, in the aliases line and in the JSON', async () => { const file = join(dir, 'retyped.bpmn'); writeFileSync(file, (await newXml({ processName: 'Invoice' })).xml); const ops = [ - { op: 'add', kind: 'startEvent', name: 'Start', in: 'Process_Invoice', as: '$s' }, + { op: 'add', kind: 'startEvent', name: 'Start', in: 'invoice', as: '$s' }, { op: 'add', kind: 'serviceTask', name: 'Check invoice', after: '$s', as: '$t' }, { op: 'retype', id: '$t', kind: 'userTask' }, ]; const json = JSON.parse(execFileSync('node', [bin, 'apply', file, '-', '--json', '--dry-run'], { encoding: 'utf8', input: JSON.stringify(ops) })); - expect(json.aliases).toEqual({ $s: 'Event_Start', $t: 'Activity_CheckInvoice' }); + expect(json.aliases).toEqual({ $s: 'startEvent_start', $t: 'userTask_checkInvoice' }); const text = execFileSync('node', [bin, 'apply', file, '-'], { encoding: 'utf8', input: JSON.stringify([...ops, { op: 'set', id: '$t', values: { name: 'Check the invoice' } }]) }); - expect(text).toMatch(/^aliases: \$s = Event_Start, \$t = Activity_CheckInvoice$/m); - expect(readFileSync(file, 'utf8')).toContain(''); + expect(text).toMatch(/^aliases: \$s = startEvent_start, \$t = userTask_checkInvoice$/m); + expect(readFileSync(file, 'utf8')).toContain(''); }); it('lists the outAs alias of a spliced gateway\'s new flow, in the aliases line and in the JSON', async () => { const file = join(dir, 'spliced.bpmn'); writeFileSync(file, await checkToOk()); const json = JSON.parse(execFileSync('node', [bin, 'apply', file, '-', '--json', '--dry-run'], { encoding: 'utf8', input: JSON.stringify(SPLICE_GATEWAY) })); - expect(json.aliases).toEqual({ $in: 'Flow_CheckToValid', $yes: 'Flow_ValidToOk', $valid: 'Gateway_Valid', $no: 'Flow_ValidToRejected' }); + expect(json.aliases).toEqual({ $in: 'flow_checkToValid', $yes: 'flow_validToOk', $valid: 'gateway_valid', $no: 'flow_validToRejected' }); const text = execFileSync('node', [bin, 'apply', file, '-'], { encoding: 'utf8', input: JSON.stringify(SPLICE_GATEWAY) }); - expect(text).toMatch(/^aliases: \$in = Flow_CheckToValid, \$yes = Flow_ValidToOk, \$valid = Gateway_Valid, \$no = Flow_ValidToRejected$/m); - expect(readFileSync(file, 'utf8')).toMatch(/\s*\s* { it('documents retype, event sub-processes, participants and scopes the way the ops behave', () => { expect(command('retype').summary).toMatch(/:none/); - expect(command('retype').examples).toContain('bpmn retype order.bpmn Event_Start startEvent:none'); - expect(DOCS).toMatch(/retype Event_X startEvent:none/); + expect(command('retype').examples).toContain('bpmn retype order.bpmn startEvent_orderReceived startEvent:none'); + expect(DOCS).toMatch(/retype startEvent_orderReceived startEvent:none/); expect(catalogue('E_EVENT_SUBPROCESS_NO_START').fix).toMatch(/one `bpmn apply`/); expect(DOCS).toMatch(/`eventSubProcess`.*one `bpmn apply`/); expect(catalogue('E_INVALID_SCOPE').meaning).not.toMatch(/collaboration/); @@ -610,10 +610,9 @@ describe('guide, README and docs: documented syntax matches the CLI', () => { expect(kindsSectionText(['ids'])).toMatch(/a word is cut inside only when no whole word is left to give up/); expect(kindsSectionText(['ids'])).toMatch(/of the cuts that fit, the one with the most words, then the most characters/); expect(DOCS).toMatch(/of the cuts that fit, the one\s+with the most words is taken, then the longest/); - // a number a cut leaves goes only where it reads like a collision suffix; a long prefix gives way in every flow form where the ends do not fit + // a number a cut leaves goes only where it reads like a collision suffix expect(DOCS).toMatch(/a number a cut would leave at the end of the id goes too where it would read\s+like a collision suffix/); expect(kindsSectionText(['ids'])).toMatch(/a number left at the end of the id goes too where it reads like a collision suffix/); - expect(kindsSectionText(['ids'])).toMatch(/a flow prefix or scope that leaves a flow's ends fewer than 16 stays where the ends fit beside it at whole words, else a named flow with Flow_/); for (const [name, text] of Object.entries(texts)) expect(text, name).not.toMatch(/cut at a word\b|an id that fits is never cut/); }); @@ -623,8 +622,8 @@ describe('guide, README and docs: documented syntax matches the CLI', () => { doc, [ { op: 'add', kind: 'start', name: 'Order received' }, - { op: 'add', kind: 'userTask', name: 'Check invoice', after: 'Event_OrderReceived' }, - { op: 'add', kind: 'end', name: 'Done', after: 'Activity_CheckInvoice' }, + { op: 'add', kind: 'userTask', name: 'Check invoice', after: 'startEvent_orderReceived' }, + { op: 'add', kind: 'end', name: 'Done', after: 'userTask_checkInvoice' }, ], { dryRun: true, layout: false, show: true }, ); diff --git a/test/domain/diagram/batch-splices.test.ts b/test/domain/diagram/batch-splices.test.ts index 499cf841..8452e04a 100644 --- a/test/domain/diagram/batch-splices.test.ts +++ b/test/domain/diagram/batch-splices.test.ts @@ -16,7 +16,7 @@ import { applyToXml, metricsXml, newXml, viewXml } from '../../../src/api.js'; /** start -> check -> gateway -(yes)-> order -> end, -(no)-> notify -> end: drawn once, then kept */ async function purchase(): Promise { const result = await applyToXml((await newXml({ processName: 'Purchase' })).xml, [ - { op: 'add', kind: 'startEvent', name: 'Request submitted', in: 'Process_Purchase', as: '$s' }, + { op: 'add', kind: 'startEvent', name: 'Request submitted', in: 'purchase', as: '$s' }, { op: 'add', kind: 'userTask', name: 'Check budget', after: '$s', as: '$c' }, { op: 'add', kind: 'exclusiveGateway', name: 'Budget available?', after: '$c', as: '$g' }, { op: 'add', kind: 'serviceTask', name: 'Order goods', after: '$g', condition: '${ok}', flowName: 'yes', flowAs: '$yes', as: '$o' }, @@ -38,13 +38,13 @@ describe('batch splices place like separate commands', () => { const flow = / { ], { layout: 'incremental' }, ); - expect((await rows(result.xml))[0]).toEqual(['Event_RequestSubmitted', 'Activity_CheckBudget', 'Gateway_BudgetAvailable', 'Activity_ApprovePurchase', 'Activity_ReserveBudget', 'Gateway_Reserved', 'Activity_OrderGoods', 'Event_GoodsOrdered']); + expect((await rows(result.xml))[0]).toEqual(['startEvent_requestSubmitted', 'userTask_checkBudget', 'gateway_budgetAvailable', 'userTask_approvePurchase', 'serviceTask_reserveBudget', 'gateway_reserved', 'serviceTask_orderGoods', 'endEvent_goodsOrdered']); expect((await metricsXml(result.xml)).score).toBe(0); }); }); @@ -85,7 +85,7 @@ describe('full layout: labels inside their pool', () => { const metrics = await metricsXml(result.xml); expect(metrics.problems.filter((problem) => problem.kind === 'labelOutsideFrame')).toEqual([]); const pool = /bpmnElement="Participant_Company"[^>]*>\s*\s*\s* { expect(modeler.shapes.get('Pool')).toBe('Pool_di'); const engine = await drawn(suffix, { diagram: 'BPMNDiagram_P', plane: 'BPMNPlane_P' }); const moved = diOf(await write(engine, [pool], { layout: 'full' })); - expect(moved.planes[0]).toMatch(/^BPMNPlane_Collaboration_/); + expect(moved.planes[0]).toMatch(/^BPMNPlane_collaboration_/); expect(moved.shapes.get('A')).toBe('A_di'); }); @@ -206,8 +206,8 @@ describe('every DI id bpmn-cli writes stays within 64 characters', () => { expect(diOf(fresh).planes).toEqual([`BPMNPlane_${cutAt(pid, 51)}`]); for (const layout of ['full', 'auto'] as const) { const after = diOf(await write(fresh, [pool], { layout, engine })); - expect(after.planes[0]).toMatch(/^BPMNPlane_Collaboration_/); - expect(after.diagrams[0]).toMatch(/^BPMNDiagram_Collaboration_/); + expect(after.planes[0]).toMatch(/^BPMNPlane_collaboration_/); + expect(after.diagrams[0]).toMatch(/^BPMNDiagram_collaboration_/); expect([...after.planes, ...after.diagrams].filter((id) => id.length > 64)).toEqual([]); } } diff --git a/test/domain/diagram/layout/clean/clean-layout.test.ts b/test/domain/diagram/layout/clean/clean-layout.test.ts index 77b9b93f..1fae533f 100644 --- a/test/domain/diagram/layout/clean/clean-layout.test.ts +++ b/test/domain/diagram/layout/clean/clean-layout.test.ts @@ -1607,7 +1607,7 @@ describe('clean layout engine', () => { const flow = flowBetween(laid, 'BE12', 'F'); // it crosses no line; the drawing keeps the crossing the lanes force (score 5) expect(crossingsOf(laid, flow.id)).toEqual([]); - expect(laid.report.crossings).toEqual(['Flow_PackOrderToInformOrderxFlow_BookDataToGateway']); + expect(laid.report.crossings).toEqual(['flow_packOrderToInformOrderxflow_bookDataToGateway']); expect(LayoutAnalysis.of(laid.out.model.definitions).score).toBeLessThanOrEqual(5); // the label to the upper right of the event, clear of the flows into and out of Pick invoice const label = laid.shape('BE12').label!, host = laid.box('A1'); @@ -1899,7 +1899,7 @@ describe('clean layout engine', () => { expect(laid.report.overlaps).toEqual([]); expect(laid.report.labelClashes).toEqual([]); // from the bottom border: score 30 (the ways below cross the branches in the lanes below, the one over the top Start's flow) - expect(laid.report.crossings).toEqual(['Flow_PackOrderToInformOrderxFlow_BookDataToGateway']); + expect(laid.report.crossings).toEqual(['flow_packOrderToInformOrderxflow_bookDataToGateway']); const host = laid.box('A1'); expect(cx(laid.box('BE14')), 'Paid (into the join) on the right').toBe(right(host) - 25); expect(cx(laid.box('BE12')), 'Payment failed (into Pack data) left of it').toBe(right(host) - 69); @@ -2247,7 +2247,7 @@ describe('clean layout engine', () => { end('CE', 'C2', 'Satisfied'), { op: 'add', kind: 'participant', id: 'Customer', name: 'Customer' }, { op: 'add', kind: 'participant', id: 'Shop', name: 'Shop' }, - { op: 'add', kind: 'startEvent:message', id: 'SS', name: 'Order received', in: 'Process_Shop', message: 'Order' }, + { op: 'add', kind: 'startEvent:message', id: 'SS', name: 'Order received', in: 'shop', message: 'Order' }, task('S1', 'Process order', 'SS'), { op: 'add', kind: 'sendTask', id: 'S2', name: 'Ship goods', after: 'S1' }, end('SE', 'S2'), @@ -2265,7 +2265,7 @@ describe('clean layout engine', () => { expect(laid.planes).toHaveLength(1); expect(idOf(laid.planes[0]!.el)).toBe(doc.collaboration()!.get('id')); // named after the process of its first pool - expect(idOf(laid.planes[0]!.el)).toBe('Collaboration_Customer'); + expect(idOf(laid.planes[0]!.el)).toBe('collaboration_customer'); const customer = laid.box('Customer'), shop = laid.box('Shop'), bank = laid.box('Bank'); // stacked vertically in declaration order, left-aligned, same width expect(customer.y + customer.height).toBeLessThan(shop.y); @@ -2652,10 +2652,10 @@ describe('clean layout engine', () => { expect(laid.report.overlaps, what).toEqual([]); // with the boundary paths the timer path goes over the top from Pick invoice's top border (it crosses nothing), and // the crossing the lanes force stays - if (full) expect(laid.report.crossings, what).toEqual(['Flow_PackOrderToInformOrderxFlow_BookDataToGateway']); + if (full) expect(laid.report.crossings, what).toEqual(['flow_packOrderToInformOrderxflow_bookDataToGateway']); if (!full) { // only the crossing the lanes force: Pack order's way up into Inform order crosses Book data's into the join - expect(laid.report.crossings).toEqual(['Flow_PackOrderToInformOrderxFlow_BookDataToGateway']); + expect(laid.report.crossings).toEqual(['flow_packOrderToInformOrderxflow_bookDataToGateway']); expect(laid.report.inOutOverlaps).toEqual([]); expectLabelsClear(laid); } diff --git a/test/domain/diagram/layout/incremental/batch-parity.test.ts b/test/domain/diagram/layout/incremental/batch-parity.test.ts index 746d2b9e..33ef5a62 100644 --- a/test/domain/diagram/layout/incremental/batch-parity.test.ts +++ b/test/domain/diagram/layout/incremental/batch-parity.test.ts @@ -45,7 +45,7 @@ const fixture = (name: string): string => readFileSync(join(ROOT, 'test', 'fixtu const approve = { op: 'add', kind: 'userTask', name: 'Approve purchase', id: 'Activity_ApprovePurchase', flow: 'Flow_0yq6cfd' }; const decision = { op: 'add', kind: 'exclusiveGateway', name: 'Purchase approved?', id: 'Gateway_PurchaseApproved', after: 'Activity_ApprovePurchase', before: 'Activity_1v0w8zr' }; -const yes = { op: 'set', id: 'Flow_PurchaseApprovedToOrderGoods', values: { name: 'yes', condition: '${purchaseApproved}' } }; +const yes = { op: 'set', id: 'flow_purchaseApprovedToOrderGoods', values: { name: 'yes', condition: '${purchaseApproved}' } }; const reminder = [ { op: 'add', kind: 'boundaryEvent:timer', name: '3 days', id: 'Event_ApprovalOverdue', on: 'Activity_ApprovePurchase', timer: 'P3D', nonInterrupting: true }, { op: 'add', kind: 'sendTask', name: 'Send reminder to manager', id: 'Activity_SendApprovalReminder', after: 'Event_ApprovalOverdue' }, @@ -173,14 +173,14 @@ describe('place --side: a boundary event on the other border of its host (issue const evb = boundsOf(down.xml, 'Event_ApprovalOverdue'); expect(Math.round(evb.y + evb.height / 2)).toBe(host.y + host.height); expect(boundsOf(down.xml, 'Activity_SendApprovalReminder').y).toBeGreaterThanOrEqual(host.y + host.height); - expect(down.result.layout?.format?.[0]?.rerouted).toContain('Flow_ApprovalOverdueToSendApprovalReminder'); + expect(down.result.layout?.format?.[0]?.rerouted).toContain('flow_approvalOverdueToSendApprovalReminder'); const applied = await applyToXml(down.xml, [{ op: 'place', ids: ['Event_ApprovalOverdue'], side: 'top' }]); const ev = boundsOf(applied.xml, 'Event_ApprovalOverdue'); expect(Math.round(ev.y + ev.height / 2)).toBe(host.y); expect(boundsOf(applied.xml, 'Activity_SendApprovalReminder').y + 80).toBeLessThanOrEqual(host.y); expect(boundsOf(applied.xml, 'Event_ReminderSent').y + 36).toBeLessThanOrEqual(host.y); expect((await metricsXml(applied.xml)).score).toBe(0); - expect(applied.result.layout?.format?.[0]?.rerouted).toContain('Flow_ApprovalOverdueToSendApprovalReminder'); + expect(applied.result.layout?.format?.[0]?.rerouted).toContain('flow_approvalOverdueToSendApprovalReminder'); } }); } @@ -197,22 +197,22 @@ describe('place --side: a boundary event on the other border of its host (issue { op: 'add', kind: 'endEvent', name: 'Invoiced', after: '$i', lane: 'Lane_Billing' }, ]); // a format command makes the drawing hand-made; the reminder path goes below the task, in its lane - const kept = (await applyToXml(drawn.xml, [{ op: 'space', after: 'Activity_CheckOrder', by: 10 }])).xml; + const kept = (await applyToXml(drawn.xml, [{ op: 'space', after: 'userTask_checkOrder', by: 10 }])).xml; const path = [ - { op: 'add', kind: 'boundaryEvent:timer', name: '2 days', id: 'Event_CheckOverdue', on: 'Activity_CheckOrder', timer: 'P2D', nonInterrupting: true }, + { op: 'add', kind: 'boundaryEvent:timer', name: '2 days', id: 'Event_CheckOverdue', on: 'userTask_checkOrder', timer: 'P2D', nonInterrupting: true }, { op: 'add', kind: 'sendTask', name: 'Remind sales', id: 'Activity_RemindSales', after: 'Event_CheckOverdue' }, { op: 'add', kind: 'endEvent', name: 'Sales reminded', id: 'Event_SalesReminded', after: 'Activity_RemindSales' }, ]; const below = (await applyToXml(kept, path)).xml; const applied = await applyToXml(below, [{ op: 'place', ids: ['Event_CheckOverdue'], side: 'top' }]); const lane = boundsOf(applied.xml, 'Lane_Sales'); - const host = boundsOf(applied.xml, 'Activity_CheckOrder'); + const host = boundsOf(applied.xml, 'userTask_checkOrder'); const remind = boundsOf(applied.xml, 'Activity_RemindSales'); expect(remind.y + remind.height).toBeLessThanOrEqual(host.y); expect(remind.y).toBeGreaterThanOrEqual(lane.y); expect(boundsOf(applied.xml, 'Event_SalesReminded').y).toBeGreaterThanOrEqual(lane.y); expect(lane.height).toBeGreaterThan(boundsOf(below, 'Lane_Sales').height); - expect(applied.result.layout?.format?.[0]?.notes?.join(' ')).toMatch(/made \d+ px of room above Activity_CheckOrder in its lane Lane_Sales/); + expect(applied.result.layout?.format?.[0]?.notes?.join(' ')).toMatch(/made \d+ px of room above userTask_checkOrder in its lane Lane_Sales/); const hard = ['overlaps', 'through', 'outsideLane', 'outsidePool', 'frameIntrusion', 'frameOverlap']; expect((await added(below, applied.xml)).filter((kind) => hard.includes(kind))).toEqual([]); }); @@ -434,10 +434,10 @@ describe('stage A verification findings (issue #18)', () => { { op: 'add', kind: 'serviceTask', name: 'Send invoice', after: '$c', lane: 'Lane_Billing', as: '$i' }, { op: 'add', kind: 'endEvent', name: 'Invoiced', after: '$i', lane: 'Lane_Billing' }, ]); - const kept = (await applyToXml(drawn.xml, [{ op: 'space', after: 'Activity_CheckOrder', by: 10 }])).xml; + const kept = (await applyToXml(drawn.xml, [{ op: 'space', after: 'userTask_checkOrder', by: 10 }])).xml; const below = await batch( [ - { op: 'add', kind: 'boundaryEvent:timer', name: '2 days', id: 'Event_CheckOverdue', on: 'Activity_CheckOrder', timer: 'P2D', nonInterrupting: true }, + { op: 'add', kind: 'boundaryEvent:timer', name: '2 days', id: 'Event_CheckOverdue', on: 'userTask_checkOrder', timer: 'P2D', nonInterrupting: true }, { op: 'add', kind: 'sendTask', name: 'Remind sales', id: 'Activity_RemindSales', after: 'Event_CheckOverdue' }, { op: 'add', kind: 'endEvent', name: 'Sales reminded', id: 'Event_SalesReminded', after: 'Activity_RemindSales' }, ], @@ -446,17 +446,17 @@ describe('stage A verification findings (issue #18)', () => { // the path above the task, then the empty rows of the lane closed: no room below the task in its lane const top = (await applyToXml(below, [{ op: 'place', ids: ['Event_CheckOverdue'], side: 'top' }])).xml; const tight = (await applyToXml(top, [{ op: 'compact', ids: ['Lane_Sales'] }])).xml; - const host = boundsOf(tight, 'Activity_CheckOrder'); + const host = boundsOf(tight, 'userTask_checkOrder'); expect(bottomOf(boundsOf(tight, 'Lane_Sales')) - (host.y + host.height)).toBeLessThan(80); const applied = await applyToXml(tight, [{ op: 'place', ids: ['Event_CheckOverdue'], side: 'bottom' }]); const lane = boundsOf(applied.xml, 'Lane_Sales'); expect(boundsOf(applied.xml, 'Activity_RemindSales').y).toBeGreaterThanOrEqual(host.y + host.height); expect(bottomOf(boundsOf(applied.xml, 'Activity_RemindSales'))).toBeLessThanOrEqual(bottomOf(lane)); expect(boundsOf(applied.xml, 'Lane_Billing').y).toBeGreaterThanOrEqual(bottomOf(lane)); - expect(applied.result.layout?.format?.[0]?.notes?.join(' ')).toMatch(/made \d+ px of room below Activity_CheckOrder in its lane Lane_Sales/); + expect(applied.result.layout?.format?.[0]?.notes?.join(' ')).toMatch(/made \d+ px of room below userTask_checkOrder in its lane Lane_Sales/); expect((await added(tight, applied.xml)).filter((kind) => hard.includes(kind))).toEqual([]); // a position below the lane, in no lane: the hint does not claim it lies in the shape's own lane - const err = (await applyToXml(kept, [{ op: 'place', ids: ['Activity_CheckOrder'], below: 'Event_Invoiced' }]).catch((error: unknown) => error)) as BpmnError; + const err = (await applyToXml(kept, [{ op: 'place', ids: ['userTask_checkOrder'], below: 'endEvent_invoiced' }]).catch((error: unknown) => error)) as BpmnError; expect(err.code).toBe('E_LEAVES_CONTAINER'); expect(String(err.details.hint)).not.toContain('That position is in lane Lane_Sales'); expect(String(err.details.hint)).toContain('below lane Lane_Sales, in no lane of its pool'); diff --git a/test/domain/diagram/layout/incremental/robustness.test.ts b/test/domain/diagram/layout/incremental/robustness.test.ts index cc18e2f9..711e93c2 100644 --- a/test/domain/diagram/layout/incremental/robustness.test.ts +++ b/test/domain/diagram/layout/incremental/robustness.test.ts @@ -237,7 +237,7 @@ describe('several diagrams, partial and unusual DI', () => { // C after D: the bridge F2 (A -> D) and D -> C (F4) get other ends, C -> D (F3) goes, C -> E is new const { result, after } = await run(xml, [{ op: 'move', ids: ['C'], after: 'D' }], { layout: 'incremental' }); expect(viewEdges(after)).toEqual([ - ['F1', 'F2', 'F4', 'F5', 'F_TaskCToE'], + ['F1', 'F2', 'F4', 'F5', 'flow_taskCToE'], ['F1', 'F5'], ]); expect(result.layout.notes).toEqual(['edge(s) of F2, F4 removed from a further diagram of the same root: their ends changed, and further diagrams are not laid out']); diff --git a/test/domain/diagram/layout/incremental/room-making.test.ts b/test/domain/diagram/layout/incremental/room-making.test.ts index f3d4577e..3171d039 100644 --- a/test/domain/diagram/layout/incremental/room-making.test.ts +++ b/test/domain/diagram/layout/incremental/room-making.test.ts @@ -166,7 +166,7 @@ describe('the local repair after placement (issue #18 (3))', () => { const applied = await applyToXml(xml, [{ op: 'add', kind: 'sendTask', name: 'Send offer', id: 'Activity_SendOffer', after: 'Activity_PrepareOffer' }], { force: true }); expect(await added(xml, applied.xml)).toEqual([]); expect(applied.result.layout?.rerouted).toContain('Flow_CollectDataToArchiveData'); - expect(applied.result.layout?.notes).toContain('Flow_CollectDataToArchiveData routed again: it crossed the new Flow_PrepareOfferToSendOffer'); + expect(applied.result.layout?.notes).toContain('Flow_CollectDataToArchiveData routed again: it crossed the new flow_prepareOfferToSendOffer'); // the hand drawing is kept otherwise: the new task where a single command puts it, nothing moved expect(applied.result.layout?.moved ?? []).toEqual([]); expect(cy(boundsOf(applied.xml, 'Activity_SendOffer'))).toBe(cy(boundsOf(xml, 'Activity_PrepareOffer'))); @@ -295,7 +295,7 @@ describe('a line along a new shape (stage B verification)', () => { for (const after of [await batch(xml, ops), await singles(xml, ops)]) { expect(await added(xml, after)).toEqual([]); const end = boundsOf(after, 'Event_ClerkReminded'); - const others = [...after.matchAll(/]*bpmnElement="([^"]+)"/g)].map((match) => match[1]!).filter((id) => id !== 'Flow_RemindClerkToClerkReminded'); + const others = [...after.matchAll(/]*bpmnElement="([^"]+)"/g)].map((match) => match[1]!).filter((id) => id !== 'flow_remindClerkToClerkReminded'); for (const id of others) expect(touches(waypoints(after, id), end), id).toBe(false); } }); diff --git a/test/domain/diagram/subprocess-titles.test.ts b/test/domain/diagram/subprocess-titles.test.ts index 76da1e88..a7f62735 100644 --- a/test/domain/diagram/subprocess-titles.test.ts +++ b/test/domain/diagram/subprocess-titles.test.ts @@ -98,15 +98,15 @@ function expectLabelsClear(xml: string, subId: string, name: string): void { } const MAIN = [ - { op: 'add', kind: 'startEvent', name: 'Kündigung eingegangen', in: 'Process_Kuendigung', as: '$s' }, + { op: 'add', kind: 'startEvent', name: 'Kündigung eingegangen', in: 'kuendigung', as: '$s' }, { op: 'add', kind: 'userTask', name: 'Kündigung prüfen', after: '$s', as: '$t' }, { op: 'add', kind: 'endEvent', name: 'Kündigung bestätigt', after: '$t' }, - { op: 'add', kind: 'eventSubProcess', name: 'Kündigung zurückgezogen', in: 'Process_Kuendigung', as: '$esp' }, + { op: 'add', kind: 'eventSubProcess', name: 'Kündigung zurückgezogen', in: 'kuendigung', as: '$esp' }, { op: 'add', kind: 'startEvent:message', name: 'Chef zieht zurück', in: '$esp', as: '$ms', message: 'Rueckzug' }, ]; const THROW = { op: 'add', kind: 'intermediateThrowEvent:compensate', name: 'Beendigung rückabwickeln' }; const END = { op: 'add', kind: 'endEvent', name: 'Prozess abgebrochen' }; -const ESP = 'Activity_KuendigungZurueckgezogen'; +const ESP = 'subProcess_kuendigungZurueckgezogen'; const ESP_NAME = 'Kündigung zurückgezogen'; async function drawnNew(ops: unknown[]): Promise { @@ -128,18 +128,18 @@ describe('full engine: labels stay off the title of an expanded sub-process and expect((await metricsXml(batch)).score).toBe(0); // the throw event between the start and the end, its label below it as the convention says const drawing = drawn(batch); - const ev = drawing.get('Event_BeendigungRueckabwickeln')!; + const ev = drawing.get('event_beendigungRueckabwickeln')!; expect(ev.label!.y).toBeGreaterThanOrEqual(bottom(ev.bounds!)); - const later = await applyToXml(await drawnNew([...MAIN, { ...END, after: '$ms' }]), [{ ...THROW, after: 'Event_ChefZiehtZurueck' }]); + const later = await applyToXml(await drawnNew([...MAIN, { ...END, after: '$ms' }]), [{ ...THROW, after: 'startEvent_chefZiehtZurueck' }]); expect(later.result.layout.mode).toBe('full'); expect(later.xml).toBe(batch); }); it('a gateway in the first row: its label above it, below the title (the frame grows at the top)', async () => { const result = await applyToXml((await newXml({ processName: 'Bestellung' })).xml, [ - { op: 'add', kind: 'startEvent', name: 'Bestellung eingegangen', in: 'Process_Bestellung', as: '$s' }, + { op: 'add', kind: 'startEvent', name: 'Bestellung eingegangen', in: 'bestellung', as: '$s' }, { op: 'add', kind: 'endEvent', name: 'Bestellung erledigt', after: '$s' }, - { op: 'add', kind: 'eventSubProcess', name: 'Stornierung durch den Kunden', in: 'Process_Bestellung', as: '$esp' }, + { op: 'add', kind: 'eventSubProcess', name: 'Stornierung durch den Kunden', in: 'bestellung', as: '$esp' }, { op: 'add', kind: 'startEvent:message', name: 'Storno angefragt', in: '$esp', as: '$ms', message: 'Storno' }, { op: 'split', @@ -152,10 +152,10 @@ describe('full engine: labels stay off the title of an expanded sub-process and ], }, ]); - const sub = 'Activity_StornierungDurchDenKunden'; + const sub = 'subProcess_stornierungDurchDenKunden'; expectLabelsClear(result.xml, sub, 'Stornierung durch den Kunden'); const { frame, title } = frameOf(result.xml, sub, 'Stornierung durch den Kunden'); - const gw = drawn(result.xml).get('Gateway_StornoMoeglich')!; + const gw = drawn(result.xml).get('gateway_stornoMoeglich')!; expect(bottom(gw.label!)).toBeLessThanOrEqual(gw.bounds!.y); expect(gw.label!.y).toBeGreaterThanOrEqual(bottom(title)); // more than the 45 px the top padding has without a label above the first row @@ -167,7 +167,7 @@ describe('full engine: labels stay off the title of an expanded sub-process and const name = 'Rücknahme der Kündigung durch den Mitarbeiter innerhalb der gesetzlichen Widerrufsfrist von zwei Wochen'; const result = await applyToXml((await newXml({ processName: 'Kuendigung' })).xml, [ ...MAIN.slice(0, 3), - { op: 'add', kind: 'eventSubProcess', name, in: 'Process_Kuendigung', as: '$esp' }, + { op: 'add', kind: 'eventSubProcess', name, in: 'kuendigung', as: '$esp' }, { op: 'add', kind: 'startEvent:message', name: 'Rücknahme eingegangen', in: '$esp', as: '$ms', message: 'Ruecknahme' }, { op: 'add', kind: 'endEvent', name: 'Kündigung storniert', after: '$ms' }, ]); @@ -182,15 +182,15 @@ describe('full engine: labels stay off the title of an expanded sub-process and it('labels wider than their events stay inside the frame on the left, the right and below', async () => { const result = await applyToXml((await newXml({ processName: 'Kuendigung' })).xml, [ ...MAIN.slice(0, 3), - { op: 'add', kind: 'eventSubProcess', name: 'Rücknahme', in: 'Process_Kuendigung', as: '$esp' }, + { op: 'add', kind: 'eventSubProcess', name: 'Rücknahme', in: 'kuendigung', as: '$esp' }, { op: 'add', kind: 'startEvent:message', name: 'Kündigungsrücknahmeerklärung eingegangen', in: '$esp', as: '$ms', message: 'Ruecknahme' }, { op: 'add', kind: 'intermediateThrowEvent:escalation', name: 'Personalabteilungsleitung informieren', after: '$ms', as: '$x', escalation: 'Info' }, { op: 'add', kind: 'endEvent', name: 'Arbeitsvertragsfortsetzung', after: '$x' }, ]); - expectLabelsClear(result.xml, 'Activity_Ruecknahme', 'Rücknahme'); + expectLabelsClear(result.xml, 'subProcess_ruecknahme', 'Rücknahme'); // the labels below their events, as the convention says (none moved onto the title row) const drawing = drawn(result.xml); - for (const id of ['Event_Kuendigungsruecknahmeerklaerung', 'Event_PersonalabteilungsleitungInformieren', 'Event_Arbeitsvertragsfortsetzung']) { + for (const id of ['startEvent_kuendigungsruecknahmeerklaerung', 'event_personalabteilungsleitungInformieren', 'endEvent_arbeitsvertragsfortsetzung']) { expect(drawing.get(id)!.label!.y).toBeGreaterThanOrEqual(bottom(drawing.get(id)!.bounds!)); } expect((await metricsXml(result.xml)).score).toBe(0); @@ -199,7 +199,7 @@ describe('full engine: labels stay off the title of an expanded sub-process and it('a loop under an event keeps below its label: the label stays at its spot below the event, inside the frame', async () => { // the reminder loop "no" runs back from Paid? to Send invoice under the timer, whose label takes three lines const result = await applyToXml((await newXml({ processName: 'Rechnung' })).xml, [ - { op: 'add', kind: 'startEvent', in: 'Process_Rechnung', as: '$s' }, + { op: 'add', kind: 'startEvent', in: 'rechnung', as: '$s' }, { op: 'add', kind: 'subProcess', name: 'Wait for the payment', after: '$s', as: '$sp' }, { op: 'add', kind: 'endEvent', name: 'Done', after: '$sp' }, { op: 'add', kind: 'startEvent', name: 'Started', in: '$sp', as: '$a' }, @@ -210,10 +210,10 @@ describe('full engine: labels stay off the title of an expanded sub-process and { op: 'connect', source: '$g', target: '$t', name: 'no' }, ]); expect(result.result.layout.mode).toBe('full'); - const sub = 'Activity_WaitForThePayment'; + const sub = 'subProcess_waitForThePayment'; expectLabelsClear(result.xml, sub, 'Wait for the payment'); const drawing = drawn(result.xml); - const timer = drawing.get('Event_TwoWeeksHavePassedSinceSending')!; + const timer = drawing.get('event_twoWeeksHavePassedSinceSending')!; expect(timer.label!.y).toBeGreaterThanOrEqual(bottom(timer.bounds!)); const loop = [...drawing.values()].find((candidate) => candidate.points && candidate.label && candidate.points.length > 3)!; expect(Math.max(...loop.points!.map((point) => point.y))).toBeGreaterThan(bottom(timer.label!)); @@ -222,26 +222,26 @@ describe('full engine: labels stay off the title of an expanded sub-process and it('data objects and stores with names of several lines below the last row: the frame holds their labels', async () => { const result = await applyToXml((await newXml({ processName: 'Application' })).xml, [ - { op: 'add', kind: 'startEvent', name: 'Application received', in: 'Process_Application', as: '$s' }, + { op: 'add', kind: 'startEvent', name: 'Application received', in: 'application', as: '$s' }, { op: 'add', kind: 'subProcess', name: 'Score', after: '$s', as: '$sp' }, { op: 'add', kind: 'startEvent', in: '$sp', as: '$a' }, { op: 'add', kind: 'task', name: 'Score applicant', after: '$a', as: '$t' }, { op: 'add', kind: 'endEvent', after: '$t' }, { op: 'add', kind: 'dataObject', name: "The applicant's complete score sheet with every detail", in: '$sp', to: '$t' }, { op: 'add', kind: 'endEvent', name: 'Application scored', after: '$sp' }, - { op: 'add', kind: 'eventSubProcess', name: 'Handle cancel', in: 'Process_Application', as: '$esp' }, + { op: 'add', kind: 'eventSubProcess', name: 'Handle cancel', in: 'application', as: '$esp' }, { op: 'add', kind: 'startEvent:message', name: 'Cancelled', in: '$esp', as: '$ms', message: 'Cancel' }, { op: 'add', kind: 'task', name: 'Log cancel', after: '$ms', as: '$log' }, { op: 'add', kind: 'endEvent', name: 'Logged', after: '$log' }, { op: 'add', kind: 'dataStore', name: 'Cancellation audit store', in: '$esp', to: '$log' }, ]); expect(result.result.layout.mode).toBe('full'); - expectLabelsClear(result.xml, 'Activity_Score', 'Score'); - expectLabelsClear(result.xml, 'Activity_HandleCancel', 'Handle cancel'); + expectLabelsClear(result.xml, 'subProcess_score', 'Score'); + expectLabelsClear(result.xml, 'subProcess_handleCancel', 'Handle cancel'); const drawing = drawn(result.xml); for (const [kind, sub] of [ - ['dataObjectReference', 'Activity_Score'], - ['dataStoreReference', 'Activity_HandleCancel'], + ['dataObjectReference', 'subProcess_score'], + ['dataStoreReference', 'subProcess_handleCancel'], ] as const) { const id = new RegExp(` { const result = await applyToXml((await newXml({ processName: 'Kuendigung' })).xml, [ - { op: 'add', kind: 'startEvent', name: 'Kündigung eingegangen', in: 'Process_Kuendigung', as: '$s' }, + { op: 'add', kind: 'startEvent', name: 'Kündigung eingegangen', in: 'kuendigung', as: '$s' }, { op: 'add', kind: 'transaction', name: 'Kündigung rückabwickeln', after: '$s', as: '$tx' }, { op: 'add', kind: 'startEvent', name: 'Rückabwicklung gestartet', in: '$tx', as: '$a' }, { op: 'add', kind: 'intermediateThrowEvent:compensate', name: 'Beendigung rückabwickeln', after: '$a', as: '$b' }, @@ -321,8 +321,8 @@ describe('full engine: labels stay off the title of an expanded sub-process and { op: 'add', kind: 'intermediateThrowEvent:signal', name: 'Personalakte aktualisieren', after: '$c', as: '$d', signal: 'Akte' }, { op: 'add', kind: 'endEvent', name: 'Aufräumen beendet', after: '$d' }, ]); - expectLabelsClear(result.xml, 'Activity_KuendigungRueckabwickeln', 'Kündigung rückabwickeln'); - expectLabelsClear(result.xml, 'Activity_ProzessAufraeumen', 'Prozess aufräumen'); + expectLabelsClear(result.xml, 'subProcess_kuendigungRueckabwickeln', 'Kündigung rückabwickeln'); + expectLabelsClear(result.xml, 'subProcess_prozessAufraeumen', 'Prozess aufräumen'); expect((await metricsXml(result.xml)).score).toBe(0); }); }); @@ -441,11 +441,11 @@ describe('incremental layout: a kept event sub-process', () => { it('`label --side above` makes room below the title instead of putting the label on it', async () => { const xml = await drawnNew([...MAIN, { ...END, after: '$ms' }, { ...THROW, after: '$ms' }]); const before = drawn(xml); - const result = await applyToXml(xml, [{ op: 'label', id: 'Event_BeendigungRueckabwickeln', side: 'above' }]); + const result = await applyToXml(xml, [{ op: 'label', id: 'event_beendigungRueckabwickeln', side: 'above' }]); expect(result.result.layout.mode).toBe('incremental'); - expect(result.result.layout.format?.[0]?.notes?.join(' ')).toMatch(/^made \d+ px of room above Event_BeendigungRueckabwickeln in its sub-process Activity_KuendigungZurueckgezogen/); + expect(result.result.layout.format?.[0]?.notes?.join(' ')).toMatch(/^made \d+ px of room above event_beendigungRueckabwickeln in its sub-process subProcess_kuendigungZurueckgezogen/); const drawing = drawn(result.xml); - const ev = drawing.get('Event_BeendigungRueckabwickeln')!; + const ev = drawing.get('event_beendigungRueckabwickeln')!; expect(bottom(ev.label!)).toBeLessThanOrEqual(ev.bounds!.y); expect(drawing.get(ESP)!.bounds!.height).toBeGreaterThan(before.get(ESP)!.bounds!.height); expectLabelsClear(result.xml, ESP, ESP_NAME); @@ -457,7 +457,7 @@ describe('incremental layout: a new event in the title row of a sub-process', () it('a branch going up from a new gateway in the first row: the label of its end event stays inside the frame, off the title', async () => { const name = 'Cancellation by the customer while the order is processed'; const init = await applyToXml((await newXml({ processName: 'M' })).xml, [ - { op: 'add', kind: 'startEvent', name: 'Order received', in: 'Process_M', id: 'S0' }, + { op: 'add', kind: 'startEvent', name: 'Order received', in: 'm', id: 'S0' }, { op: 'add', kind: 'task', name: 'Fill in the inspection report for quality assurance', after: 'S0', id: 'T0' }, { op: 'add', kind: 'subProcess', name, after: 'T0', id: 'SP' }, { op: 'add', kind: 'startEvent', name: 'Start', in: 'SP', id: 'S' }, diff --git a/test/domain/document/core.test.ts b/test/domain/document/core.test.ts index ec5d0694..66c0474f 100644 --- a/test/domain/document/core.test.ts +++ b/test/domain/document/core.test.ts @@ -25,10 +25,10 @@ async function compensationDoc(): Promise { runOps(doc, [ { op: 'add', kind: 'startEvent', id: 'S' }, { op: 'add', kind: 'serviceTask', name: 'Charge card', after: 'S' }, - { op: 'add', kind: 'endEvent', id: 'E', after: 'Activity_ChargeCard' }, - { op: 'add', kind: 'boundaryEvent:compensate', id: 'Comp', on: 'Activity_ChargeCard' }, + { op: 'add', kind: 'endEvent', id: 'E', after: 'serviceTask_chargeCard' }, + { op: 'add', kind: 'boundaryEvent:compensate', id: 'Comp', on: 'serviceTask_chargeCard' }, { op: 'add', kind: 'serviceTask', name: 'Refund card', in: 'P', set: { isForCompensation: 'true' } }, - { op: 'connect', source: 'Comp', target: 'Activity_RefundCard' }, + { op: 'connect', source: 'Comp', target: 'serviceTask_refundCard' }, ]); return doc; } @@ -44,33 +44,33 @@ describe('ids', () => { expect(slugify('a'.repeat(60)).length).toBe(40); }); - it('generates Prefix_Slug ids, kind + context ids for unnamed elements and flows named after their ends, without collisions (default style)', () => { - const ids = new IdRegistry(['Activity_CheckInvoice', 'Flow_3', 'Flow_7']); + it('generates prefix_slug ids, kind + context ids for unnamed elements and flows named after their ends, without collisions (the defaults)', () => { + const ids = new IdRegistry(['task_checkInvoice', 'Flow_3', 'Flow_7']); + const style = IdStyle.of(); const next = (req: Parameters[0]): string => { - const { id } = IdStyle.DEFAULT.next(req, ids); + const { id } = style.next(req, ids); ids.claim(id); return id; }; const task = kindByName('task')!; - expect(next(kindRequest(task, { name: 'Check invoice' }))).toBe('Activity_CheckInvoice_2'); - expect(next(kindRequest(task, { name: 'Check invoice' }))).toBe('Activity_CheckInvoice_3'); + expect(next(kindRequest(task, { name: 'Check invoice' }))).toBe('task_checkInvoice2'); + expect(next(kindRequest(task, { name: 'Check invoice' }))).toBe('task_checkInvoice3'); // unnamed (a name without words counts as none): the context, after a word for the kind where the prefix does not say it const gw = kindByName('exclusiveGateway')!; - expect(next(kindRequest(gw, { name: '???', context: 'After Check invoice' }))).toBe('Gateway_AfterCheckInvoice'); + expect(next(kindRequest(gw, { name: '???', context: 'After Check invoice' }))).toBe('gateway_afterCheckInvoice'); // a taken one spells out its kind first, then takes a suffix - expect(next(kindRequest(gw, { context: 'After Check invoice' }))).toBe('Gateway_ExclusiveAfterCheckInvoice'); - expect(next(kindRequest(gw, { context: 'After Check invoice' }))).toBe('Gateway_AfterCheckInvoice_2'); - // so does an unnamed task whose body an unnamed gateway's id has (the flow between them names both apart) - expect(next(kindRequest(task, { context: 'After Check invoice' }))).toBe('Activity_TaskAfterCheckInvoice'); - expect(next(kindRequest(gw))).toBe('Gateway_Exclusive'); - expect(next(kindRequest(kindByName('boundaryEvent')!, { trigger: 'timer', context: 'On Check invoice' }))).toBe('Event_TimerOnCheckInvoice'); - expect(next(kindRequest(kindByName('startEvent')!, { trigger: 'message' }))).toBe('Event_MessageStart'); - expect(next(typeRequest('bpmn:LaneSet', { context: 'Order handling' }))).toBe('LaneSet_OrderHandling'); - expect(next(typeRequest('bpmn:Process'))).toBe('Process_Main'); + expect(next(kindRequest(gw, { context: 'After Check invoice' }))).toBe('gateway_exclusiveAfterCheckInvoice'); + expect(next(kindRequest(gw, { context: 'After Check invoice' }))).toBe('gateway_afterCheckInvoice2'); + expect(next(kindRequest(task, { context: 'After Check invoice' }))).toBe('task_afterCheckInvoice'); + expect(next(kindRequest(gw))).toBe('gateway_exclusive'); + expect(next(kindRequest(kindByName('boundaryEvent')!, { trigger: 'timer', context: 'On Check invoice' }))).toBe('event_timerOnCheckInvoice'); + expect(next(kindRequest(kindByName('startEvent')!, { trigger: 'message' }))).toBe('startEvent_message'); + expect(next(typeRequest('bpmn:LaneSet', { context: 'Order handling' }))).toBe('laneSet_orderHandling'); + expect(next(typeRequest('bpmn:Process'))).toBe('Process_1'); // flows: the labels of their ends (default: the speaking part of the end ids); numbers in the registry play no part - const flow = connectionRequest('bpmn:SequenceFlow', 'Activity_CheckInvoice', 'Activity_0k3x9qa', { targetLabel: 'Book invoice' }); - expect(next(flow)).toBe('Flow_CheckInvoiceToBookInvoice'); - expect(IdStyle.DEFAULT.next(flow, ids)).toEqual({ id: 'Flow_CheckInvoiceToBookInvoice_2', base: 'Flow_CheckInvoiceToBookInvoice' }); + const flow = connectionRequest('bpmn:SequenceFlow', 'task_checkInvoice', 'Activity_0k3x9qa', { targetLabel: 'Book invoice' }); + expect(next(flow)).toBe('flow_checkInvoiceToBookInvoice'); + expect(style.next(flow, ids)).toEqual({ id: 'flow_checkInvoiceToBookInvoice2', base: 'flow_checkInvoiceToBookInvoice' }); }); it('validates NCNames', () => { @@ -124,7 +124,7 @@ describe('kinds', () => { describe('document', () => { it('creates a new document with a named process', async () => { const doc = Doc.create({ processName: 'Order handling', target: 'camunda8' }); - expect(doc.processes()[0]!.get('id')).toBe('Process_OrderHandling'); + expect(doc.processes()[0]!.get('id')).toBe('orderHandling'); const xml = await doc.toXml(); expect(xml).toContain('xmlns:zeebe='); expect(xml).toContain('modeler:executionPlatform="Camunda Cloud"'); @@ -175,13 +175,13 @@ describe('flows & placement', () => { it('splices after a connected task and appends after a gateway', async () => { const doc = await linearDoc(); const cs = new ChangeSet(); - const task = doc.create('bpmn:ServiceTask', { id: new EditSession(doc).newId('Activity', 'Check stock'), name: 'Check stock' }); + const task = doc.create('bpmn:ServiceTask', { id: new EditSession(doc).allocator.allocate(kindRequest(kindByName('serviceTask')!, { name: 'Check stock' })).id, name: 'Check stock' }); new NodePlacement(new EditSession(doc), { after: 'Task_A', flowName: 'ok' }).place(task, cs); expect(cs.notes).toContain('inserted between Task_A and End'); - expect(doc.outgoing(doc.get('Task_A')!).map((flow) => flow.get('targetRef').id)).toEqual(['Activity_CheckStock']); + expect(doc.outgoing(doc.get('Task_A')!).map((flow) => flow.get('targetRef').id)).toEqual(['serviceTask_checkStock']); expect(doc.get('F2')!.get('name')).toBe('ok'); const gw = doc.create('bpmn:ExclusiveGateway', { id: 'G', name: 'ok?' }); - new NodePlacement(new EditSession(doc), { after: 'Activity_CheckStock' }).place(gw, new ChangeSet()); + new NodePlacement(new EditSession(doc), { after: 'serviceTask_checkStock' }).place(gw, new ChangeSet()); const e2 = doc.create('bpmn:EndEvent', { id: 'E2' }); const cs2 = new ChangeSet(); new NodePlacement(new EditSession(doc), { after: 'G', flowName: 'no', default: true }).place(e2, cs2); @@ -348,11 +348,11 @@ describe('layout', () => { { op: 'add', kind: 'task', id: 'B', after: 'A' }, { op: 'add', kind: 'endEvent', id: 'E', after: 'B' }, { op: 'add', kind: 'dataObject', name: 'Doc', in: 'P' }, - { op: 'connect', source: 'A', target: 'DataObjectReference_Doc' }, - { op: 'connect', source: 'DataObjectReference_Doc', target: 'B' }, + { op: 'connect', source: 'A', target: 'dataObject_doc' }, + { op: 'connect', source: 'dataObject_doc', target: 'B' }, { op: 'add', kind: 'lane', name: 'L1', in: 'P', members: ['S', 'A', 'E'] }, ]); - const members = () => many(doc.get('Lane_L1')!, 'flowNodeRef').map((node) => node.get('id')); + const members = () => many(doc.get('lane_l1')!, 'flowNodeRef').map((node) => node.get('id')); expect(members()).toEqual(['S', 'A', 'E']); const layoutOutcome = await new FullLayout(doc.model).run(); expect(layoutOutcome.xml).toMatch(/BPMNShape_B"/); @@ -360,7 +360,7 @@ describe('layout', () => { expect(members()).toEqual(['S', 'A', 'E']); const written = await Doc.fromXml(layoutOutcome.xml); expect(written.importWarnings).toHaveLength(0); - expect(many(written.get('Lane_L1')!, 'flowNodeRef').map((node) => node.get('id'))).toEqual(['S', 'A', 'E']); + expect(many(written.get('lane_l1')!, 'flowNodeRef').map((node) => node.get('id'))).toEqual(['S', 'A', 'E']); expect(written.lanesOf(written.get('B')!)).toHaveLength(0); }); @@ -374,7 +374,7 @@ describe('layout', () => { const shapes = new Map(); for (const diagram of many(laidOut.definitions, 'diagrams')) for (const shape of many(diagram.get('plane'), 'planeElement')) shapes.set(shape.get('bpmnElement').id, shape); const event = shapes.get('Comp').bounds; - const handler = shapes.get('Activity_RefundCard').bounds; + const handler = shapes.get('serviceTask_refundCard').bounds; expect(handler.x + handler.width / 2).toBe(event.x + event.width / 2); expect(handler.y).toBeGreaterThan(event.y + event.height); const edge = shapes.get(association); @@ -388,7 +388,7 @@ describe('layout', () => { const cs = runOps(doc, [{ op: 'add', kind: 'textAnnotation', text: 'on the flow', to: 'F1' }]); const association = cs.created.find((change) => change.kind === 'association')!.id; // F1 says nothing about its ends (a number): a flow - expect(association).toBe('Association_FlowToOnTheFlow'); + expect(association).toBe('association_flowToOnTheFlow'); const layoutOutcome = await new FullLayout(doc.model).run(); expect(layoutOutcome.warnings).toEqual([]); expect(layoutOutcome.xml).toContain(`BPMNEdge_${association}`); diff --git a/test/domain/document/ids/default-ids.test.ts b/test/domain/document/ids/default-ids.test.ts new file mode 100644 index 00000000..2fa312c9 --- /dev/null +++ b/test/domain/document/ids/default-ids.test.ts @@ -0,0 +1,100 @@ +/** + * New ids follow the declared id conventions, else element-id-naming's defaults, whatever the + * file's existing ids look like; types the rule does not check take bpmn-cli's own prefix. + */ +import { describe, expect, it } from 'vitest'; +import { applyToXml, newXml } from '../../../../src/api.js'; +import { IdConventionsReader } from '../../../../src/adapters/out/bpmnlint/IdConventionsReader.js'; +import { DEFAULT_ID_CONVENTIONS, type IdConventions } from '../../../../src/domain/document/ids/IdConventions.js'; +import { bpmnlintEngine } from '../../../../src/node.js'; +import { definitionsXml } from '../../../helpers/helpers.js'; + +const ids = (xml: string): string[] => [...xml.matchAll(/]*?\sid="([^"]+)"/g)].map((match) => match[1]!); + +const MODELER_FILE = definitionsXml(` + Flow_0a1b2c3 + Flow_0a1b2c3 + `); + +const withCase = (bodyCase: IdConventions['case']): IdConventions => ({ ...DEFAULT_ID_CONVENTIONS, case: bodyCase }); + +describe('new ids without a .bpmnlintrc', () => { + it('follow the defaults, not the ids the file already has', async () => { + const { xml } = await applyToXml(MODELER_FILE, [{ op: 'add', kind: 'userTask', name: 'Ship order', after: 'Activity_CheckOrder' }], { layout: false }); + expect(ids(xml)).toEqual(expect.arrayContaining(['Activity_CheckOrder', 'userTask_shipOrder', 'flow_checkOrderToShipOrder'])); + }); + + it('give a pool, a lane, a message and a data object the default prefixes, the backing data object its own', async () => { + const { xml } = await applyToXml(MODELER_FILE, [ + { op: 'add', kind: 'participant', name: 'Customer' }, + { op: 'add', kind: 'lane', name: 'Clerk', in: 'participant_customer' }, + { op: 'add', kind: 'dataObject', name: 'Invoice', in: 'Process_1' }, + { op: 'add', kind: 'intermediateCatchEvent:message', name: 'Paid', message: 'Payment received', after: 'Activity_CheckOrder' }, + ], { layout: false }); + expect(ids(xml)).toEqual(expect.arrayContaining(['participant_customer', 'lane_clerk', 'dataObject_invoice', 'dataObjectItem_invoice', 'message_paymentReceived', 'event_paid'])); + }); + + it('name a process after its name alone; an unnamed one Process_1', async () => { + expect(ids((await newXml({ processName: 'Order to cash' })).xml)).toEqual(expect.arrayContaining(['definitions_orderToCash', 'orderToCash'])); + expect(ids((await newXml()).xml)).toEqual(expect.arrayContaining(['Definitions_1', 'Process_1'])); + }); + + it("give a new pool's process the pool's name, with a suffix where the name is taken", async () => { + const { xml: named } = await newXml({ processName: 'Order handling' }); + const { xml } = await applyToXml(named, [ + { op: 'add', kind: 'participant', name: 'Shop' }, + { op: 'add', kind: 'participant', name: 'Order handling' }, + { op: 'add', kind: 'participant', name: 'Billing' }, + ], { layout: false }); + expect(ids(xml)).toEqual(expect.arrayContaining(['participant_shop', 'orderHandling', 'participant_orderHandling', 'orderHandling2', 'participant_billing', 'billing'])); + }); +}); + +describe('declared conventions', () => { + it('beat the defaults; types they leave out take bpmn-cli prefixes shaped like the declared ones', async () => { + const conventions: IdConventions = { prefixes: { 'bpmn:UserTask': 'Activity_', 'bpmn:SequenceFlow': 'Flow_' }, case: 'PascalCase' }; + const { xml } = await applyToXml(MODELER_FILE, [ + { op: 'add', kind: 'userTask', name: 'Ship order', after: 'Activity_CheckOrder' }, + { op: 'add', kind: 'intermediateCatchEvent:message', name: 'Paid', message: 'Payment received', after: 'Activity_ShipOrder' }, + ], { layout: false, conventions }); + expect(ids(xml)).toEqual(expect.arrayContaining(['Activity_ShipOrder', 'Flow_CheckOrderToShipOrder', 'Message_PaymentReceived', 'IntermediateCatchEvent_Paid'])); + }); + + it('a declared bpmn:Process prefix wins over the bare name', async () => { + const conventions: IdConventions = { prefixes: { ...DEFAULT_ID_CONVENTIONS.prefixes, 'bpmn:Process': 'process_' }, case: 'camelCase' }; + expect(ids((await newXml({ processName: 'Order to cash', conventions })).xml)).toContain('process_orderToCash'); + }); + + it('kebab-case writes kebab bodies, flows and suffixes', async () => { + const { xml } = await applyToXml(MODELER_FILE, [ + { op: 'add', kind: 'serviceTask', name: 'Claim membership', after: 'Activity_CheckOrder' }, + { op: 'add', kind: 'serviceTask', name: 'Claim membership', after: 'serviceTask_claim-membership' }, + ], { layout: false, conventions: withCase('kebab-case') }); + expect(ids(xml)).toEqual(expect.arrayContaining(['serviceTask_claim-membership', 'serviceTask_claim-membership-2', 'flow_claim-membership-to-claim-membership-2'])); + }); +}); + +describe('flow ids', () => { + const LINE = definitionsXml(` + Flow_AToBflow_aToC + Flow_AToB + flow_aToC + + `); + + it('only a flow in the current form follows its ends; an older one keeps its id', async () => { + const { xml } = await applyToXml(LINE, [ + { op: 'set', id: 'Flow_AToB', values: { target: 'task_c' } }, + { op: 'set', id: 'flow_aToC', values: { target: 'task_b' } }, + ], { layout: false }); + expect(ids(xml)).toEqual(expect.arrayContaining(['Flow_AToB', 'flow_aToB'])); + expect(ids(xml)).not.toContain('flow_aToC'); + }); +}); + +describe('DEFAULT_ID_CONVENTIONS', () => { + it("are element-id-naming's defaults as the plugin resolves them", () => { + const { conventions } = new IdConventionsReader(bpmnlintEngine().plugin.naming).read({ severity: 'warn', config: {} }); + expect(conventions).toEqual(DEFAULT_ID_CONVENTIONS); + }); +}); diff --git a/test/domain/document/ids/drawn-ids.test.ts b/test/domain/document/ids/drawn-ids.test.ts index 50da3e2d..e3e69c89 100644 --- a/test/domain/document/ids/drawn-ids.test.ts +++ b/test/domain/document/ids/drawn-ids.test.ts @@ -1,6 +1,6 @@ /** * A full redraw of a file with id-less elements (collaboration, participant, - * process, message flow, sequence flow) gives them ids in the file's style + * process, message flow, sequence flow) gives them ids in the id conventions * first: never bpmnElement="undefined" or repeated `_undefined` DI ids. An * op that gives a sequence flow without id other ends gives it an id from * its new ends. Synthetic fixtures only. @@ -44,21 +44,20 @@ async function sound(xml: string): Promise { } describe('ids for id-less elements a full redraw draws', () => { - it('a file without diagram: the collaboration, the pool and the message flows get ids in the file\'s style, each its own DI', async () => { + it('a file without diagram: the collaboration, the pool and the message flows get ids in the id conventions, each its own DI', async () => { const outcome = await applyToXml(COLLAB, [{ op: 'set', id: 'Task_1', values: { name: 'Check' } }]); await sound(outcome.xml); - // the file's style: its pool is Part_B, its flows Flow_ (the prefix); the bodies speak: the collaboration is named - // after the process of its first pool, the message flows after their ends - expect(outcome.xml).toContain(''); - expect(outcome.xml).toContain(''); - expect(outcome.xml).toContain(''); - expect(outcome.xml).toContain(''); - for (const id of ['Collaboration_A', 'Flow_CustomerToCheck', 'Flow_CheckToCustomer', 'Part_Shop', 'Part_B']) expect(outcome.xml).toMatch(new RegExp(`'); + expect(outcome.xml).toContain(''); + expect(outcome.xml).toContain(''); + expect(outcome.xml).toContain(''); + for (const id of ['collaboration_a', 'messageFlow_customerToCheck', 'messageFlow_checkToCustomer', 'participant_shop', 'Part_B']) expect(outcome.xml).toMatch(new RegExp(` change.detail?.startsWith('id added')).map((change) => [change.kind, change.id])).toEqual([ - ['collaboration', 'Collaboration_A'], - ['participant', 'Part_Shop'], - ['messageFlow', 'Flow_CustomerToCheck'], - ['messageFlow', 'Flow_CheckToCustomer'], + ['collaboration', 'collaboration_a'], + ['participant', 'participant_shop'], + ['messageFlow', 'messageFlow_customerToCheck'], + ['messageFlow', 'messageFlow_checkToCustomer'], ]); expect(outcome.result.notes).toContainEqual(expect.stringContaining('4 element(s) without id got one')); // the same write gives the same ids @@ -76,10 +75,10 @@ describe('ids for id-less elements a full redraw draws', () => { for (const engine of ['clean', 'auto'] as const) { const outcome = await layoutXml(xml, { engine }); await sound(outcome.xml); - // the one process of the file without name and pool: Main; flows after their ends (Start_1 says nothing: Start) - expect(outcome.xml).toContain(''); - expect(outcome.xml).toContain(''); - expect([...outcome.xml.matchAll(/; flows after their ends (Start_1 says nothing: Start) + expect(outcome.xml).toContain(''); + expect(outcome.xml).toContain(''); + expect([...outcome.xml.matchAll(/ { ${LINE} - + `); const doc = await Doc.fromXml(xml); const outcome = await mutateDoc(doc, [], { layout: 'full', force: true }); await sound(outcome.xml); // P2 says nothing (a number) and has no name: a participant - expect(outcome.xml).toContain(''); - expect(doc.ids.has('Flow_ParticipantToCheck_2')).toBe(true); + expect(outcome.xml).toContain(''); + expect(doc.ids.has('messageFlow_participantToCheck2')).toBe(true); }); it('a kept (incremental) drawing leaves id-less elements alone: a no-op stays unchanged', async () => { @@ -131,22 +130,22 @@ ${LINE.replace(' `${change.id}: ${change.detail}`)).toEqual(['Flow_CheckToLog: Task_1 -> Task_Log (was -> End_1) (id added: it had none)']); - expect(outcome.result.created.map((change) => change.id)).toEqual(['Task_Log', 'Flow_LogToEnd']); + expect(flows(outcome.xml)).toEqual(['id="Flow_1" sourceRef="Start_1" targetRef="Task_1"', 'id="flow_checkToLog" sourceRef="Task_1" targetRef="task_log"', 'id="flow_logToEnd" sourceRef="task_log" targetRef="End_1"']); + expect(outcome.result.changed.map((change) => `${change.id}: ${change.detail}`)).toEqual(['flow_checkToLog: Task_1 -> task_log (was -> End_1) (id added: it had none)']); + expect(outcome.result.created.map((change) => change.id)).toEqual(['task_log', 'flow_logToEnd']); expect([outcome.result.renamed, outcome.result.warnings.added]).toEqual([undefined, []]); } const split = await applyToXml(OUT, [{ op: 'split', after: 'Task_1', name: 'Ok?', branches: [{ flowName: 'yes', nodes: [{ kind: 'task', name: 'A' }] }, { flowName: 'no', nodes: [] }] }], { keepFlowIds, layout: false }); - expect(split.result.changed.map((change) => `${change.id}: ${change.detail}`)).toContain('Flow_CheckToOk: Task_1 -> Gateway_Ok (was -> End_1) (id added: it had none)'); - expect(flows(split.xml)).toContain('id="Flow_CheckToOk" sourceRef="Task_1" targetRef="Gateway_Ok"'); + expect(split.result.changed.map((change) => `${change.id}: ${change.detail}`)).toContain('flow_checkToOk: Task_1 -> gateway_ok (was -> End_1) (id added: it had none)'); + expect(flows(split.xml)).toContain('id="flow_checkToOk" sourceRef="Task_1" targetRef="gateway_ok"'); } }); it('add --flow-id on a splice into it: the flow takes that id (claimed like the id of a flow add creates), without W_OPTION_IGNORED', async () => { for (const keepFlowIds of [false, true]) { const outcome = await applyToXml(OUT, [{ op: 'add', kind: 'task', name: 'Log', after: 'Task_1', flowId: 'Flow_IntoLog' }], { keepFlowIds, layout: false }); - expect(flows(outcome.xml)).toEqual(['id="Flow_1" sourceRef="Start_1" targetRef="Task_1"', 'id="Flow_IntoLog" sourceRef="Task_1" targetRef="Task_Log"', 'id="Flow_LogToEnd" sourceRef="Task_Log" targetRef="End_1"']); - expect(outcome.result.changed.map((change) => `${change.id}: ${change.detail}`)).toEqual(['Flow_IntoLog: Task_1 -> Task_Log (was -> End_1) (id added: it had none)']); + expect(flows(outcome.xml)).toEqual(['id="Flow_1" sourceRef="Start_1" targetRef="Task_1"', 'id="Flow_IntoLog" sourceRef="Task_1" targetRef="task_log"', 'id="flow_logToEnd" sourceRef="task_log" targetRef="End_1"']); + expect(outcome.result.changed.map((change) => `${change.id}: ${change.detail}`)).toEqual(['Flow_IntoLog: Task_1 -> task_log (was -> End_1) (id added: it had none)']); expect(outcome.result.warnings.added).toEqual([]); await expect(applyToXml(OUT, [{ op: 'add', kind: 'task', name: 'Log', after: 'Task_1', flowId: 'Flow_1' }], { keepFlowIds, layout: false })).rejects.toMatchObject({ code: 'E_DUPLICATE_ID' }); } @@ -163,15 +162,15 @@ ${LINE.replace('/); - expect(outcome.result.changed.map((change) => change.id)).toContain('Flow_CheckToLog'); + expect(outcome.xml).toMatch(//); + expect(outcome.result.changed.map((change) => change.id)).toContain('flow_checkToLog'); } }); it('a bridge gives the flow into the removed node an id; a removed flow without id prints `(no id)` and is left out of the summary', async () => { const bridged = await applyToXml(IN, [{ op: 'remove', ids: ['Task_1'] }], { layout: false }); - expect(flows(bridged.xml)).toEqual(['id="Flow_StartToEnd" sourceRef="Start_1" targetRef="End_1"']); - expect(bridged.result.changed.map((change) => `${change.id}: ${change.detail}`)).toEqual(['Flow_StartToEnd: Start_1 -> End_1 (bridged Task_1) (id added: it had none)']); + expect(flows(bridged.xml)).toEqual(['id="flow_startToEnd" sourceRef="Start_1" targetRef="End_1"']); + expect(bridged.result.changed.map((change) => `${change.id}: ${change.detail}`)).toEqual(['flow_startToEnd: Start_1 -> End_1 (bridged Task_1) (id added: it had none)']); const removed = await applyToXml(OUT, [{ op: 'remove', ids: ['Task_1'] }], { layout: false }); const text = renderMutation(removed.result); expect(text).toContain('removed sequenceFlow (no id) - Task_1 -> End_1\n'); diff --git a/test/domain/document/ids/id-quality.test.ts b/test/domain/document/ids/id-quality.test.ts index 90a7cec9..a001565a 100644 --- a/test/domain/document/ids/id-quality.test.ts +++ b/test/domain/document/ids/id-quality.test.ts @@ -10,9 +10,7 @@ import { describe, expect, it } from 'vitest'; import { applyToXml, newXml, validateXml } from '../../../../src/api.js'; import { Doc } from '../../../../src/domain/document/Doc.js'; -import { conventionsView } from '../../../../src/index.js'; -import { ID_CONVENTIONS } from '../../../../src/adapters/in/report/guide.js'; -import { cutAt, cutName, idWords, isFiller, keptTail, MAX_ID, slugify, snakeSlug, transliterate } from '../../../../src/domain/document/utils/slug.js'; +import { camelSlug, cutAt, cutName, idWords, isFiller, keptTail, MAX_ID, slugify, snakeSlug, transliterate } from '../../../../src/domain/document/utils/slug.js'; import { IdRegistry } from '../../../../src/domain/document/ids/IdRegistry.js'; import { connectionRequest, kindRequest } from '../../../../src/domain/document/utils/requests.js'; import { IdStyle } from '../../../../src/domain/document/ids/IdStyle.js'; @@ -22,15 +20,15 @@ import { definitionsXml, flowBetween } from '../../../helpers/helpers.js'; const ids = (xml: string): string[] => [...xml.matchAll(/ match[1]!); const flowIds = (xml: string): string[] => [...xml.matchAll(/ match[1]!); -/** a flow id with a side that is only a word for a kind (Flow_GatewayToJoin, Flow_CheckToGateway, Flow_TaskToTask) */ -const GENERIC_SIDE = /(^Flow_|To)(Gateway|Join|Task|Timer|Catch)(To|$|_\d+$)/; +/** a flow id with a side that is only a word for a kind (flow_gatewayToJoin, flow_checkToGateway, flow_taskToTask) */ +const GENERIC_SIDE = /(^flow_|To)(gateway|Gateway|Join|Task|Timer|Catch)(To|$|\d+$)/; /** The verifier's batch: an exclusive split after Check, a parallel split after Fix, every gateway unnamed. */ const NESTED_SPLITS: unknown[] = [ - { op: 'add', kind: 'startEvent', name: 'Start', in: 'Process_Gw', as: '$s' }, + { op: 'add', kind: 'startEvent', name: 'Start', in: 'gw', as: '$s' }, { op: 'add', kind: 'task', name: 'Check', after: '$s', as: '$c' }, { op: 'split', after: '$c', kind: 'exclusiveGateway', branches: [{ flowName: 'yes', condition: '${a}', nodes: [{ kind: 'task', name: 'Fix' }] }, { flowName: 'no', default: true, nodes: [] }] }, - { op: 'split', after: 'Activity_Fix', kind: 'parallelGateway', branches: [{ nodes: [{ kind: 'task', name: 'A' }] }, { nodes: [] }] }, + { op: 'split', after: 'task_fix', kind: 'parallelGateway', branches: [{ nodes: [{ kind: 'task', name: 'A' }] }, { nodes: [] }] }, ]; describe('flows at unnamed gateways name them by their speaking ids', () => { @@ -39,15 +37,15 @@ describe('flows at unnamed gateways name them by their speaking ids', () => { const flows = flowIds(applied.xml); expect(flows.sort()).toEqual( [ - 'Flow_StartToCheck', - 'Flow_CheckToAfterCheck', - 'Flow_AfterCheckToFix', - 'Flow_AfterCheckToCheckJoin', - 'Flow_FixToAfterFix', - 'Flow_AfterFixToA', - 'Flow_AfterFixToFixJoin', - 'Flow_AToFixJoin', - 'Flow_FixJoinToCheckJoin', + 'flow_startToCheck', + 'flow_checkToAfterCheck', + 'flow_afterCheckToFix', + 'flow_afterCheckToCheckJoin', + 'flow_fixToAfterFix', + 'flow_afterFixToA', + 'flow_afterFixToFixJoin', + 'flow_aToFixJoin', + 'flow_fixJoinToCheckJoin', ].sort(), ); for (const id of flows) expect(id).not.toMatch(GENERIC_SIDE); @@ -58,7 +56,7 @@ describe('flows at unnamed gateways name them by their speaking ids', () => { it('unnamed gateways and tasks in a row: the nearest named anchor once, the kind spelled out, then the anchor named', async () => { const base = await newXml({ processName: 'Row' }); const start = [ - { op: 'add', kind: 'startEvent', name: 'Start', in: 'Process_Row', as: '$s' }, + { op: 'add', kind: 'startEvent', name: 'Start', in: 'row', as: '$s' }, { op: 'add', kind: 'task', name: 'Check', after: '$s', as: '$c' }, ]; const run = async (ops: unknown[]): Promise<{ aliases: Record; flows: string[]; suffixed: number }> => { @@ -71,18 +69,18 @@ describe('flows at unnamed gateways name them by their speaking ids', () => { { op: 'add', kind: 'parallelGateway', after: '$g', as: '$h' }, { op: 'add', kind: 'endEvent', after: '$h', as: '$e' }, ]); - expect(gw.aliases).toMatchObject({ $g: 'Gateway_AfterCheck', $h: 'Gateway_ParallelAfterCheck', $e: 'Event_EndAfterCheck' }); - expect(gw.flows).toEqual(['Flow_AfterCheckToParallelAfterCheck', 'Flow_CheckToAfterCheck', 'Flow_ParallelAfterCheckToEndAfterCheck', 'Flow_StartToCheck']); + expect(gw.aliases).toMatchObject({ $g: 'gateway_afterCheck', $h: 'gateway_parallelAfterCheck', $e: 'endEvent_afterCheckGateway' }); + expect(gw.flows).toEqual(['flow_afterCheckToParallelAfterCheck', 'flow_checkToAfterCheck', 'flow_parallelAfterCheckToAfterCheckGateway', 'flow_startToCheck']); expect(gw.suffixed).toBe(0); - // an unnamed task after the unnamed gateway: its body would be the gateway's (Flow_AfterCheckToAfterCheck), so it says its kind; - // the next one would repeat that, so it names its anchor (the Check service task), not the gateway's body again + // an unnamed task after the unnamed gateway: its body would be the gateway's (flow_afterCheckToAfterCheck), and its prefix + // says its kind, so it names its anchor's kind; the next one names its own anchor's kind after that const tasks = await run([ { op: 'add', kind: 'exclusiveGateway', after: '$c', as: '$g' }, { op: 'add', kind: 'serviceTask', after: '$g', as: '$u' }, { op: 'add', kind: 'serviceTask', after: '$u', as: '$v' }, ]); - expect(tasks.aliases).toMatchObject({ $g: 'Gateway_AfterCheck', $u: 'Activity_ServiceTaskAfterCheck', $v: 'Activity_AfterCheckServiceTask' }); - expect(tasks.flows).toEqual(expect.arrayContaining(['Flow_AfterCheckToServiceTaskAfterCheck', 'Flow_ServiceTaskAfterCheckToAfterCheckServiceTask'])); + expect(tasks.aliases).toMatchObject({ $g: 'gateway_afterCheck', $u: 'serviceTask_afterCheckGateway', $v: 'serviceTask_afterCheckGatewayServiceTask' }); + expect(tasks.flows).toEqual(expect.arrayContaining(['flow_afterCheckToAfterCheckGateway', 'flow_afterCheckGatewayToAfterCheckGatewayServiceTask'])); expect(tasks.suffixed).toBe(0); // a split right after an unnamed split: no suffix, the flows of each split and join apart const splits = await run([ @@ -90,11 +88,11 @@ describe('flows at unnamed gateways name them by their speaking ids', () => { { op: 'split', after: '$g', kind: 'parallelGateway', branches: [{ nodes: [{ kind: 'task', name: 'Y' }] }, { nodes: [] }] }, ]); expect(splits.suffixed).toBe(0); - expect(splits.flows).toEqual(expect.arrayContaining(['Flow_AfterCheckToParallelAfterCheck', 'Flow_ParallelAfterCheckToY', 'Flow_YToParallelAfterCheckJoin'])); + expect(splits.flows).toEqual(expect.arrayContaining(['flow_afterCheckToParallelAfterCheck', 'flow_parallelAfterCheckToY', 'flow_yToParallelAfterCheckJoin'])); for (const id of [...Object.values(gw.aliases), ...gw.flows, ...Object.values(tasks.aliases), ...tasks.flows, ...splits.flows]) expect(id).not.toMatch(/AfterAfter|_\d+$/); }); - it('a flow renamed after its new ends uses the same rule; one named by an earlier version (Flow_CheckToGateway) still follows its ends', async () => { + it('a flow an earlier version named (Flow_GatewayToShip) keeps its id when a splice changes its ends; the new flow follows the current rule', async () => { const xml = definitionsXml(` Flow_StartToCheck Flow_StartToCheckFlow_CheckToGateway @@ -106,25 +104,26 @@ describe('flows at unnamed gateways name them by their speaking ids', () => { // a no-op keeps every id const noop = await applyToXml(xml, [{ op: 'set', id: 'Activity_Ship', values: { name: 'Ship' } }], { layout: false }); expect(noop.unchanged).toBe(true); - // a splice renames the old flow after its new ends, by the current rule + // the old form is not the current one: the spliced flow keeps its id, the new one names its ends const doc = await Doc.fromXml(xml); const cs = runOps(doc, [{ op: 'add', kind: 'task', name: 'Pack', flow: 'Flow_GatewayToShip' }]); - expect(flowBetween(doc, 'Gateway_AfterCheck', 'Activity_Pack')).toBe('Flow_AfterCheckToPack'); - expect(cs.renames).toEqual([expect.objectContaining({ from: 'Flow_GatewayToShip', to: 'Flow_AfterCheckToPack' })]); + expect(flowBetween(doc, 'Gateway_AfterCheck', 'task_pack')).toBe('Flow_GatewayToShip'); + expect(cs.renames).toEqual([]); + expect(cs.created.map((change) => change.id)).toEqual(['task_pack', 'flow_packToShip']); expect(flowBetween(doc, 'Activity_Check', 'Gateway_AfterCheck')).toBe('Flow_CheckToGateway'); }); }); /** Bodies of ids without their prefix: `Task_AfterCheck` and `ExclusiveGateway_AfterCheck` share `AfterCheck`. */ const body = (id: string): string => id.slice(id.indexOf('_') + 1); -/** A flow whose two ends read alike (Flow_AfterCheckToAfterCheck, flow_after_checkToAfter_check). */ +/** A flow whose two ends read alike (flow_afterCheckToAfterCheck, flow_after_checkToAfter_check). */ const SAME_ENDS = /^[A-Za-z]+_(\w+?)(?:To|_to_)(\w+)$/; const sameEnds = (id: string): boolean => { const match = SAME_ENDS.exec(id); return !!match && match[1]!.toLowerCase() === match[2]!.toLowerCase(); }; -/** A file of the older Camunda Modeler: `Task_` for every task, gateways and flows named after their type with a hash. */ +/** A file of the older Camunda Modeler: hashed ids. */ const OLD_MODELER = definitionsXml(` SequenceFlow_0a1b2c3 SequenceFlow_0a1b2c3SequenceFlow_1d2e3f4 @@ -135,15 +134,7 @@ const OLD_MODELER = definitionsXml(` `); -/** snake_case bodies with `task_` for every task. */ -const SNAKE = definitionsXml(` - flow_1 - flow_1flow_2 - flow_2 - - `); - -describe('unnamed elements in a row where the prefix says the kind (Task_, task_)', () => { +describe('unnamed elements in a row where the prefix says the kind (task_)', () => { const run = async (xml: string, ops: unknown[]) => { const applied = await applyToXml(xml, ops, { layout: false }); const before = new Set(ids(xml)); @@ -158,30 +149,22 @@ describe('unnamed elements in a row where the prefix says the kind (Task_, task_ { op: 'add', kind: 'task', after: '$g', as: '$u' }, { op: 'add', kind: 'task', after: '$u', as: '$v' }, ]); - // before: Task_AfterCheck (the gateway's body: SequenceFlow_AfterCheckToAfterCheck) and Task_AfterCheck_2 with W_ID_SUFFIXED - expect(applied.aliases).toMatchObject({ $g: 'ExclusiveGateway_AfterCheck', $u: 'Task_AfterCheckGateway', $v: 'Task_AfterCheckGatewayTask' }); - expect(applied.flows).toEqual(expect.arrayContaining(['SequenceFlow_AfterCheckToAfterCheckGateway', 'SequenceFlow_AfterCheckGatewayToAfterCheckGatewayTask'])); + // before: task_afterCheck (the gateway's body: flow_afterCheckToAfterCheck) and task_afterCheck2 with W_ID_SUFFIXED + expect(applied.aliases).toMatchObject({ $g: 'gateway_afterCheck', $u: 'task_afterCheckGateway', $v: 'task_afterCheckGatewayTask' }); + expect(applied.flows).toEqual(expect.arrayContaining(['flow_afterCheckToAfterCheckGateway', 'flow_afterCheckGatewayToAfterCheckGatewayTask'])); expect(applied.suffixed).toEqual([]); const bodies = nodes(applied.fresh, applied.flows).map(body); expect(new Set(bodies).size).toBe(bodies.length); for (const id of applied.flows) expect(sameEnds(id)).toBe(false); }); - it('the same in a snake_case file, and after a task whose name gives no id words', async () => { - const snake = await run(SNAKE, [ - { op: 'add', kind: 'exclusiveGateway', after: 'task_check_order', as: '$g' }, - { op: 'add', kind: 'task', after: '$g', as: '$u' }, - { op: 'add', kind: 'task', after: '$u', as: '$v' }, - ]); - expect(snake.aliases).toMatchObject({ $u: 'task_after_check_order_gateway', $v: 'task_after_check_order_gateway_task' }); - expect(snake.suffixed).toEqual([]); - for (const id of snake.flows) expect(sameEnds(id)).toBe(false); - // a service task named in CJK and a task named with symbols: both unnamed for their ids, both Task_ + it('after a task whose name gives no id words', async () => { + // a service task named in CJK and a task named with symbols: both unnamed for their ids const tricky = await run(OLD_MODELER, [ { op: 'add', kind: 'serviceTask', name: '审批 订单', after: 'Task_0k3x9qa', as: '$c' }, { op: 'add', kind: 'task', name: '✓✓✓', after: '$c', as: '$k' }, ]); - expect(tricky.aliases).toMatchObject({ $c: 'Task_AfterCheck', $k: 'Task_AfterCheckServiceTask' }); + expect(tricky.aliases).toMatchObject({ $c: 'serviceTask_afterCheck', $k: 'task_afterCheckServiceTask' }); expect(tricky.suffixed).toEqual([]); }); @@ -191,7 +174,7 @@ describe('unnamed elements in a row where the prefix says the kind (Task_, task_ { op: 'add', kind: 'exclusiveGateway', after: '$l', as: '$g' }, { op: 'add', kind: 'task', after: '$g', as: '$u' }, ]); - expect(applied.aliases['$u']).toMatch(/^Task_After\w+Gateway$/); + expect(applied.aliases['$u']).toMatch(/^task_after\w+Gateway$/); for (const id of applied.fresh) expect(id.length).toBeLessThanOrEqual(MAX_ID); expect(applied.suffixed).toEqual([]); }); @@ -202,13 +185,13 @@ describe('unnamed elements in a row where the prefix says the kind (Task_, task_ { op: 'add', kind: 'task', name: 'Ship', after: 'Task_0k3x9qa' }, { op: 'add', kind: 'task', name: 'Ship', after: 'Task_1m2n3o4' }, ]); - expect(named.fresh).toEqual(expect.arrayContaining(['Task_Ship', 'Task_Ship_2'])); - expect(named.suffixed.map((suffixWarning) => suffixWarning.element)).toEqual(['Task_Ship_2']); + expect(named.fresh).toEqual(expect.arrayContaining(['task_ship', 'task_ship2'])); + expect(named.suffixed.map((suffixWarning) => suffixWarning.element)).toEqual(['task_ship2']); // two unnamed tasks on two branches right after the same unnamed gateway: the second one takes a suffix on the - // form that names the gateway, never the gateway's own body (SequenceFlow_AfterCheckToAfterCheck) + // form that names the gateway, never the gateway's own body (flow_afterCheckToAfterCheck) const branches = await run(OLD_MODELER, [{ op: 'split', after: 'Task_0k3x9qa', kind: 'parallelGateway', branches: [{ nodes: [{ kind: 'task' }] }, { nodes: [{ kind: 'task' }] }] }]); - expect(branches.fresh).toEqual(expect.arrayContaining(['Task_AfterCheckGateway', 'Task_AfterCheckGateway_2'])); - expect(branches.suffixed.map((suffixWarning) => suffixWarning.element)).toEqual(['Task_AfterCheckGateway_2']); + expect(branches.fresh).toEqual(expect.arrayContaining(['task_afterCheckGateway', 'task_afterCheckGateway2'])); + expect(branches.suffixed.map((suffixWarning) => suffixWarning.element)).toEqual(['task_afterCheckGateway2']); for (const id of branches.flows) expect(sameEnds(id)).toBe(false); }); }); @@ -217,7 +200,7 @@ describe('id quality: context once, length cap, transliteration', () => { it('an unnamed element after an unnamed one is placed after the nearest named anchor, once (no AfterAfter chains)', async () => { const base = await newXml({ processName: 'Tricky' }); const applied = await applyToXml(base.xml, [ - { op: 'add', kind: 'startEvent', name: 'Start', in: 'Process_Tricky', as: '$s' }, + { op: 'add', kind: 'startEvent', name: 'Start', in: 'tricky', as: '$s' }, { op: 'add', kind: 'userTask', name: '4-Augen-Prinzip: prüfen & freigeben (2. Stufe)?', after: '$s', as: '$q' }, { op: 'add', kind: 'serviceTask', name: '审批 订单', after: '$q', as: '$c' }, { op: 'add', kind: 'task', name: '✓✓✓', after: '$c', as: '$d' }, @@ -228,28 +211,29 @@ describe('id quality: context once, length cap, transliteration', () => { for (const id of all) expect(id).not.toMatch(/AfterAfter|BeforeBefore/); expect(applied.result.warnings.added.map((warning) => warning.code)).not.toContain('W_ID_SUFFIXED'); expect(applied.result.aliases).toMatchObject({ - $q: 'Activity_4AugenPrinzipPruefenFreigeben2Stufe', - $c: 'Activity_After4AugenPrinzipPruefenFreigeben2Stufe', - // two unnamed tasks after the same named anchor: the second spells out its kind (no suffix; the body is cut at 40 characters) - $d: 'Activity_TaskAfter4AugenPrinzipPruefenFreigeben2', - $g: 'Gateway_After4AugenPrinzipPruefenFreigeben2', + // a body that would begin with a number begins with the kind word + $q: 'userTask_userTask4AugenPrinzipPruefenFreigeben2', + $c: 'serviceTask_afterUserTask4AugenPrinzipPruefen2', + // two unnamed elements after the same named anchor: the second names the first one's kind (no suffix; the body is cut at 40 characters) + $d: 'task_afterUserTask4AugenPrinzip2ServiceTask', + $g: 'gateway_exclusiveAfterUserTask4AugenPrinzip2', // a body of more than 40 characters is cut at a word boundary - $e: 'Event_EndAfter4AugenPrinzipPruefenFreigeben2', + $e: 'endEvent_afterUserTask4AugenPrinzip2', }); }); it('a name without letters names nothing: no numbered id', async () => { const base = await newXml({ processName: 'Digits' }); const applied = await applyToXml(base.xml, [ - { op: 'add', kind: 'startEvent', name: 'Start', in: 'Process_Digits', as: '$s' }, + { op: 'add', kind: 'startEvent', name: 'Start', in: 'digits', as: '$s' }, { op: 'add', kind: 'task', name: '123', after: '$s', as: '$t' }, ]); - expect(applied.result.aliases?.['$t']).toBe('Activity_AfterStart'); + expect(applied.result.aliases?.['$t']).toBe('task_afterStart'); }); it('generated ids are at most 64 characters, cut at whole words, the same every time', async () => { const ops = [ - { op: 'add', kind: 'startEvent', name: 'Start', in: 'Process_Long', as: '$s' }, + { op: 'add', kind: 'startEvent', name: 'Start', in: 'long', as: '$s' }, { op: 'add', kind: 'userTask', name: 'Prüfen ob die eingereichten Unterlagen vollständig und fristgerecht vorliegen', after: '$s', as: '$a' }, { op: 'add', kind: 'serviceTask', name: 'Benachrichtigung über den Bearbeitungsstand an alle beteiligten Stellen versenden', after: '$a', as: '$b' }, { op: 'add', kind: 'boundaryEvent:message', on: '$b', message: 'Rückmeldung der beteiligten Stellen zum Bearbeitungsstand eingegangen' }, @@ -257,15 +241,15 @@ describe('id quality: context once, length cap, transliteration', () => { const base = await newXml({ processName: 'Long' }); const applied = await applyToXml(base.xml, ops); for (const id of ids(applied.xml)) expect(id.length).toBeLessThanOrEqual(MAX_ID); - // a name gives at most 40 characters of words, although Activity_BenachrichtigungUeberDenBearbeitungsstand fits into 64, - // and they never end on a filler word (before: Activity_BenachrichtigungUeberDen) - expect(applied.result.aliases).toMatchObject({ $a: 'Activity_PruefenObDieEingereichtenUnterlagen', $b: 'Activity_BenachrichtigungUeber' }); + // a name gives at most 40 characters of words, although serviceTask_benachrichtigungUeberDenBearbeitungsstand fits into 64, + // and they never end on a filler word (before: serviceTask_benachrichtigungUeberDen) + expect(applied.result.aliases).toMatchObject({ $a: 'userTask_pruefenObDieEingereichtenUnterlagen', $b: 'serviceTask_benachrichtigungUeber' }); // a flow's two ends share the room: of the cuts with the most words the longest, the target gives up Den, not the - // source Unterlagen (0.6 and the first cut of #31: Flow_PruefenObDieEingereichtenToBenachrichtigungUeberDen) - expect(flowIds(applied.xml)).toContain('Flow_PruefenObDieEingereichtenUnterlagenToBenachrichtigungUeber'); + // source Unterlagen (0.6 and the first cut of #31: flow_pruefenObDieEingereichtenToBenachrichtigungUeberDen) + expect(flowIds(applied.xml)).toContain('flow_pruefenObDieEingereichtenUnterlagenToBenachrichtigungUeber'); expect((await applyToXml(base.xml, ops)).xml).toBe(applied.xml); - expect(cutAt('Flow_CheckInvoiceToBookInvoice', 23)).toBe('Flow_CheckInvoiceToBook'); - expect(cutAt('Flow_CheckInvoiceToBookInvoice', 22)).toBe('Flow_CheckInvoiceTo'); + expect(cutAt('flow_checkInvoiceToBookInvoice', 23)).toBe('flow_checkInvoiceToBook'); + expect(cutAt('flow_checkInvoiceToBookInvoice', 22)).toBe('flow_checkInvoiceTo'); expect(cutAt('check_invoice_and_book_it', 15)).toBe('check_invoice'); expect(cutAt('Abcdefghijklmnopqrstuvwxyz', 10)).toBe('Abcdefghij'); expect(cutAt('Activity_URLCheckAndMore', 17)).toBe('Activity_URLCheck'); @@ -279,7 +263,7 @@ describe('id quality: context once, length cap, transliteration', () => { expect(idWords('check_invoice')).toEqual(['check', '_invoice']); expect(idWords('CheckInvoice2')).toEqual(['Check', 'Invoice', '2']); expect(idWords('URLCheck')).toEqual(['URLCheck']); - expect(idWords('Flow_PruefenToBenachrichtigung_2').join('')).toBe('Flow_PruefenToBenachrichtigung_2'); + expect(idWords('flow_pruefenToBenachrichtigung2').join('')).toBe('flow_pruefenToBenachrichtigung2'); expect([keptTail('CheckJoin'), keptTail('Check_join_2'), keptTail('Check_2'), keptTail('2'), keptTail('Check')]).toEqual(['Join', '_join_2', '_2', '', '']); }); @@ -305,7 +289,7 @@ describe('id quality: context once, length cap, transliteration', () => { /** The issue's model: a splice after ` synchronisieren` renames the flow to the end ` aktualisiert`. */ async function sync(word: string) { const first = await applyToXml((await newXml({ processName: 'Sync' })).xml, [ - { op: 'add', kind: 'startEvent', name: 'Every minute', in: 'Process_Sync', as: '$s' }, + { op: 'add', kind: 'startEvent', name: 'Every minute', in: 'sync', as: '$s' }, { op: 'add', kind: 'serviceTask', name: `${word} synchronisieren`, after: '$s', as: '$a' }, { op: 'add', kind: 'endEvent', name: `${word} aktualisiert`, after: '$a' }, ]); @@ -314,13 +298,13 @@ describe('id quality: context once, length cap, transliteration', () => { it('#31: an id that fits into 64 characters is not cut (a renamed flow included)', async () => { const applied = await sync('Consignations'); - expect(flowIds(applied.xml)).toContain('Flow_ConsignationsSynchronisierenToConsignationsProtokollieren'); - expect(applied.result.renamed).toEqual({ Flow_ConsignationsSynchronisierenToConsignationsAktualisiert: 'Flow_ConsignationsSynchronisierenToConsignationsProtokollieren' }); - expect(flowIds((await sync('Deliveriesabc')).xml)).toContain('Flow_DeliveriesabcSynchronisierenToDeliveriesabcProtokollieren'); - const full = 'Flow_DeliveriesabcdSynchronisierenToDeliveriesabcdProtokollieren'; + expect(flowIds(applied.xml)).toContain('flow_consignationsSynchronisierenToConsignationsProtokollieren'); + expect(applied.result.renamed).toEqual({ flow_consignationsSynchronisierenToConsignationsAktualisiert: 'flow_consignationsSynchronisierenToConsignationsProtokollieren' }); + expect(flowIds((await sync('Deliveriesabc')).xml)).toContain('flow_deliveriesabcSynchronisierenToDeliveriesabcProtokollieren'); + const full = 'flow_deliveriesabcdSynchronisierenToDeliveriesabcdProtokollieren'; expect(full).toHaveLength(MAX_ID); expect(flowIds((await sync('Deliveriesabcd')).xml)).toContain(full); - expect(flowIds((await sync('Shipments')).xml)).toContain('Flow_ShipmentsSynchronisierenToShipmentsProtokollieren'); + expect(flowIds((await sync('Shipments')).xml)).toContain('flow_shipmentsSynchronisierenToShipmentsProtokollieren'); }); it('#31: a camelCase flow that fits is not cut either', async () => { @@ -338,12 +322,12 @@ describe('id quality: context once, length cap, transliteration', () => { it('#31: a cut drops whole words, the longer end first; ends that begin alike keep their last word', async () => { // 66 characters uncut: the source, one letter longer, gives up the word the two ends share - expect(flowIds((await sync('Deliveriesabcde')).xml)).toContain('Flow_SynchronisierenToDeliveriesabcdeProtokollieren'); - // 0.6: Flow_LieferantenbSynchronisierenToLieferantenbeProtokollieren - expect(flowIds((await sync('Lieferantenbestellungen')).xml)).toContain('Flow_SynchronisierenToLieferantenbestellungenProtokollieren'); + expect(flowIds((await sync('Deliveriesabcde')).xml)).toContain('flow_synchronisierenToDeliveriesabcdeProtokollieren'); + // 0.6: flow_lieferantenbSynchronisierenToLieferantenbeProtokollieren + expect(flowIds((await sync('Lieferantenbestellungen')).xml)).toContain('flow_synchronisierenToLieferantenbestellungenProtokollieren'); // the target gives up Abgleichen, then the source Lieferantenbestellungsuebersicht: Abgleichen fits again and comes back (not ...PruefenUnd) - const flow = (labels: { sourceLabel: string; targetLabel: string }): string => IdStyle.DEFAULT.derivedBase(connectionRequest('bpmn:SequenceFlow', 'Activity_A', 'Activity_B', labels)); - expect(flow({ sourceLabel: 'Neue Lieferantenbestellungsübersicht', targetLabel: 'Zahlungseingänge prüfen und abgleichen' })).toBe('Flow_NeueToZahlungseingaengePruefenUndAbgleichen'); + const flow = (labels: { sourceLabel: string; targetLabel: string }): string => IdStyle.of().derivedBase(connectionRequest('bpmn:SequenceFlow', 'Activity_A', 'Activity_B', labels)); + expect(flow({ sourceLabel: 'Neue Lieferantenbestellungsübersicht', targetLabel: 'Zahlungseingänge prüfen und abgleichen' })).toBe('flow_neueToZahlungseingaengePruefenUndAbgleichen'); // name pairs of 1 to 6 words (a third begin alike, some end in a number): every id within 64 characters, the // uncut one where it fits, else every word of it a whole word of the names (an end cut to its first words that // begins unlike the other has no room for its next word), and the same id every time @@ -362,16 +346,16 @@ describe('id quality: context once, length cap, transliteration', () => { const sourceLabel = name(); const targetLabel = pick(3) === 0 ? name(sourceLabel.split(' ')[0]) : name(); const req = connectionRequest('bpmn:SequenceFlow', 'Activity_A', 'Activity_B', { sourceLabel, targetLabel }); - const id = IdStyle.DEFAULT.derivedBase(req); - const uncut = `Flow_${slugify(sourceLabel)}To${slugify(targetLabel)}`; - const known = new Set([...idWords(slugify(sourceLabel)), ...idWords(slugify(targetLabel)), 'To']); + const id = IdStyle.of().derivedBase(req); + const uncut = `flow_${camelSlug(sourceLabel)}To${slugify(targetLabel)}`; + const known = new Set([...idWords(camelSlug(sourceLabel)), ...idWords(slugify(targetLabel)), 'To'].map((word) => word.toLowerCase())); expect(id.length, id).toBeLessThanOrEqual(MAX_ID); if (uncut.length <= MAX_ID) expect(id).toBe(uncut); else { - const words = idWords(id.slice('Flow_'.length)); - for (const word of words) expect(known, `${word} in ${id}`).toContain(word); - const [full, other] = [idWords(slugify(sourceLabel)), idWords(slugify(targetLabel))]; - if (full[0] !== other[0]) { + const words = idWords(id.slice('flow_'.length)); + for (const word of words) expect(known, `${word} in ${id}`).toContain(word.toLowerCase()); + const [full, other] = [idWords(camelSlug(sourceLabel)), idWords(slugify(targetLabel))]; + if (full[0]!.toLowerCase() !== other[0]!.toLowerCase()) { const at = words.indexOf('To'); for (const [cut, whole] of [[words.slice(0, at), full], [words.slice(at + 1), other]] as const) { // a number goes with the word after it (an end never ends on a number it did not end with), so does a filler @@ -383,82 +367,68 @@ describe('id quality: context once, length cap, transliteration', () => { } } } - expect(IdStyle.DEFAULT.derivedBase(req)).toBe(id); + expect(IdStyle.of().derivedBase(req)).toBe(id); } }); it('#31: a word is cut inside only when no end has a word left to give up, and then in the longer end where the other fits whole', () => { - const flow = (sourceLabel: string, targetLabel: string): string => IdStyle.DEFAULT.derivedBase(connectionRequest('bpmn:SequenceFlow', 'Activity_A', 'Activity_B', { sourceLabel, targetLabel })); + const flow = (sourceLabel: string, targetLabel: string): string => IdStyle.of().derivedBase(connectionRequest('bpmn:SequenceFlow', 'Activity_A', 'Activity_B', { sourceLabel, targetLabel })); // one word each, 32 and 30 characters in a room of 57 (before: both cut, ...ToZahlungseingangsbestaetigunge) - expect(flow('Lieferantenbestellungsübersicht', 'Zahlungseingangsbestätigungen')).toBe('Flow_LieferantenbestellungsueberToZahlungseingangsbestaetigungen'); + expect(flow('Lieferantenbestellungsübersicht', 'Zahlungseingangsbestätigungen')).toBe('flow_lieferantenbestellungsueberToZahlungseingangsbestaetigungen'); // each end first gives up its second word; on a tie the source is cut - expect(flow('Kundenstammdatenaktualisierung durchführen', 'Vertragsunterlagenarchivierung abschließen')).toBe('Flow_KundenstammdatenaktualisierToVertragsunterlagenarchivierung'); + expect(flow('Kundenstammdatenaktualisierung durchführen', 'Vertragsunterlagenarchivierung abschließen')).toBe('flow_kundenstammdatenaktualisierToVertragsunterlagenarchivierung'); // ends that begin alike are down to their last word - expect(flow('Bestellung Lieferantenstammdatenprotokoll', 'Bestellung Warenwirtschaftssystemabgleich')).toBe('Flow_LieferantenstammdatenprotokToWarenwirtschaftssystemabgleich'); + expect(flow('Bestellung Lieferantenstammdatenprotokoll', 'Bestellung Warenwirtschaftssystemabgleich')).toBe('flow_lieferantenstammdatenprotokToWarenwirtschaftssystemabgleich'); }); it('#31: of the cuts that fit, the one with the most words, then the most characters: a short word goes before a long one', async () => { - const flow = (sourceLabel: string, targetLabel: string): string => IdStyle.DEFAULT.derivedBase(connectionRequest('bpmn:SequenceFlow', 'Activity_A', 'Activity_B', { sourceLabel, targetLabel })); - // 66 characters uncut: Aktualisieren goes, not Lieferantenbestellungen (the longer end first: Flow_SynchronisierenAktualisierenToRechnung) - expect(flow('Synchronisieren aktualisieren', 'Rechnung Lieferantenbestellungen')).toBe('Flow_SynchronisierenToRechnungLieferantenbestellungen'); - // both keep two words: the longer pair (not Flow_VersendenToAbgleichenBestellungDie) - expect(flow('Versenden Zahlungseingangsbestätigung', 'Abgleichen Bestellung die')).toBe('Flow_VersendenZahlungseingangsbestaetigungToAbgleichenBestellung'); - // a stemTo end keeps its words too (not flow_pruefenUebermittelnInformierenToUnd) - const xml = definitionsXml(` - flow_goToCheckOrder - flow_goToCheckOrderflow_checkOrderToShipOrder - flow_checkOrderToShipOrder - - `); - const stemTo = IdStyle.infer((await Doc.fromXml(xml)).definitions); - expect(stemTo.info().sequenceFlow.form).toBe('stemTo'); - expect(stemTo.derivedBase(connectionRequest('bpmn:SequenceFlow', 'task_pruefenUebermittelnInformieren', 'task_undZahlungseingangsbestaetigungKundeAn'))).toBe('flow_pruefenUebermittelnToUndZahlungseingangsbestaetigungKundeAn'); + const flow = (sourceLabel: string, targetLabel: string): string => IdStyle.of().derivedBase(connectionRequest('bpmn:SequenceFlow', 'Activity_A', 'Activity_B', { sourceLabel, targetLabel })); + // 66 characters uncut: Aktualisieren goes, not Lieferantenbestellungen (the longer end first: flow_synchronisierenAktualisierenToRechnung) + expect(flow('Synchronisieren aktualisieren', 'Rechnung Lieferantenbestellungen')).toBe('flow_synchronisierenToRechnungLieferantenbestellungen'); + // both keep two words: the longer pair (not flow_versendenToAbgleichenBestellungDie) + expect(flow('Versenden Zahlungseingangsbestätigung', 'Abgleichen Bestellung die')).toBe('flow_versendenZahlungseingangsbestaetigungToAbgleichenBestellung'); + // ends named by their ids keep their words too (not flow_pruefenUebermittelnInformierenToUnd) + expect(IdStyle.of().derivedBase(connectionRequest('bpmn:SequenceFlow', 'task_pruefenUebermittelnInformieren', 'task_undZahlungseingangsbestaetigungKundeAn'))).toBe('flow_pruefenUebermittelnToUndZahlungseingangsbestaetigungKundeAn'); }); it('#31: an end never shrinks to a number, and the id never ends on one that reads like a suffix; a word between numbers is cut inside, the numbers kept', async () => { - const flow = (sourceLabel: string, targetLabel: string): string => IdStyle.DEFAULT.derivedBase(connectionRequest('bpmn:SequenceFlow', 'Activity_A', 'Activity_B', { sourceLabel, targetLabel })); + const flow = (sourceLabel: string, targetLabel: string): string => IdStyle.of().derivedBase(connectionRequest('bpmn:SequenceFlow', 'Activity_A', 'Activity_B', { sourceLabel, targetLabel })); // the word between two numbers stays (no 32024), so the end is cut inside it, and the other end keeps what fits - // beside it (0.6: Flow_32024ToLieferantenbestellungsuebersichtPruefen; before this fix: ...ToLieferantenbestellungsuebersicht, 44 characters) - expect(flow('3 Zahlungseingangsbestätigungen 2024', 'Lieferantenbestellungsübersicht prüfen')).toBe('Flow_3Zahlungseingangsbest2024ToLieferantenbestellungsuebersicht'); - expect(flow('Lieferantenbestellungsübersicht prüfen', '3 Zahlungseingangsbestätigungen 2024')).toBe('Flow_LieferantenbestellungsuebersichtTo3Zahlungseingangsbest2024'); - expect(flow('2 Zahlungseingangsbestätigungen 2', 'Lieferantenbestellungsübersicht prüfen')).toBe('Flow_2Zahlungseingangsbestaet2ToLieferantenbestellungsuebersicht'); - expect(flow('10 Warenwirtschaftssystemabgleich 10', 'Warenwirtschaftssystemabgleich 100')).toBe('Flow_10Warenwirtschaftssyst10ToWarenwirtschaftssystemabgleich100'); + // beside it (0.6: Flow_32024ToLieferantenbestellungsuebersichtPruefen); a source that begins with a number begins + // with From, which is no word the end keeps instead of the number and the word after it + expect(flow('3 Zahlungseingangsbestätigungen 2024', 'Lieferantenbestellungsübersicht prüfen')).toBe('flow_from3Zahlungseingangs2024ToLieferantenbestellungsuebersicht'); + expect(flow('Lieferantenbestellungsübersicht prüfen', '3 Zahlungseingangsbestätigungen 2024')).toBe('flow_lieferantenbestellungsuebersichtTo3Zahlungseingangsbest2024'); + expect(flow('2 Zahlungseingangsbestätigungen 2', 'Lieferantenbestellungsübersicht prüfen')).toBe('flow_from2Zahlungseingangsbes2ToLieferantenbestellungsuebersicht'); + expect(flow('10 Warenwirtschaftssystemabgleich 10', 'Warenwirtschaftssystemabgleich 100')).toBe('flow_from10Warenwirtschafts10ToWarenwirtschaftssystemabgleich100'); // a leading number keeps the word after it (0.6: Flow_2ToVertragsunterlagenarchivierung) - expect(flow('2 Lieferantenbestellungsübersichten', 'Vertragsunterlagenarchivierung abschließen')).toBe('Flow_2LieferantenbestellungsuebeToVertragsunterlagenarchivierung'); + expect(flow('2 Lieferantenbestellungsübersichten', 'Vertragsunterlagenarchivierung abschließen')).toBe('flow_from2LieferantenbestellungsToVertragsunterlagenarchivierung'); // the same through an edit const applied = await applyToXml((await newXml({ processName: 'Num' })).xml, [ - { op: 'add', kind: 'startEvent', name: 'Go', in: 'Process_Num', as: '$s' }, + { op: 'add', kind: 'startEvent', name: 'Go', in: 'num', as: '$s' }, { op: 'add', kind: 'task', name: '3 Zahlungseingangsbestätigungen 2024', after: '$s', as: '$a' }, { op: 'add', kind: 'task', name: 'Lieferantenbestellungsübersicht prüfen', after: '$a' }, ]); - expect(flowIds(applied.xml)).toContain('Flow_3Zahlungseingangsbest2024ToLieferantenbestellungsuebersicht'); - // a number the given-up word leaves stays where it cannot read like a collision suffix (`_2` here): before To, and - // glued to the target's last word; so two flows that differ only by their number keep their own ids (before this - // fix both Flow_PhaseToZahlungseingangsbestaetigungenAbgleichen, the second one with _2) - expect(flow('Zahlungseingangsbestätigungen', 'Lieferantenbestellungen 4 Augen ab')).toBe('Flow_ZahlungseingangsbestaetigungenToLieferantenbestellungen4'); - expect(flow('Phase 1 Lieferantenbestellungsübersicht prüfen', 'Zahlungseingangsbestätigungen abgleichen')).toBe('Flow_Phase1ToZahlungseingangsbestaetigungenAbgleichen'); - expect(flow('Phase 2 Lieferantenbestellungsübersicht prüfen', 'Zahlungseingangsbestätigungen abgleichen')).toBe('Flow_Phase2ToZahlungseingangsbestaetigungenAbgleichen'); - expect(flow('V2 Lieferantenbestellungsübersicht', 'Zahlungseingangsbestätigungen abgleichen')).toBe('Flow_V2ToZahlungseingangsbestaetigungenAbgleichen'); - expect(flow('Zahlungseingangsbestätigungen abgleichen', 'Stufe 2 Lieferantenbestellungsübersicht prüfen')).toBe('Flow_ZahlungseingangsbestaetigungenAbgleichenToStufe2'); + expect(flowIds(applied.xml)).toEqual(expect.arrayContaining(['flow_task2024ToLieferantenbestellungsuebersichtPruefen'])); + // a number the given-up word leaves stays where it cannot read like a collision suffix (a glued `2` here): before To; + // so two flows that differ only by their number keep their own ids (before this fix both + // Flow_PhaseToZahlungseingangsbestaetigungenAbgleichen, the second one with _2); at the end of the id it goes + expect(flow('Zahlungseingangsbestätigungen', 'Lieferantenbestellungen 4 Augen ab')).toBe('flow_zahlungseingangsbestaetigungenToLieferantenbestellungen'); + expect(flow('Phase 1 Lieferantenbestellungsübersicht prüfen', 'Zahlungseingangsbestätigungen abgleichen')).toBe('flow_phase1ToZahlungseingangsbestaetigungenAbgleichen'); + expect(flow('Phase 2 Lieferantenbestellungsübersicht prüfen', 'Zahlungseingangsbestätigungen abgleichen')).toBe('flow_phase2ToZahlungseingangsbestaetigungenAbgleichen'); + expect(flow('V2 Lieferantenbestellungsübersicht', 'Zahlungseingangsbestätigungen abgleichen')).toBe('flow_v2ToZahlungseingangsbestaetigungenAbgleichen'); + expect(flow('Zahlungseingangsbestätigungen abgleichen', 'Stufe 2 Lieferantenbestellungsübersicht prüfen')).toBe('flow_zahlungseingangsbestaetigungenAbgleichenToStufe'); const phases = await applyToXml((await newXml({ processName: 'Phase' })).xml, [ - { op: 'add', kind: 'startEvent', name: 'Go', in: 'Process_Phase', as: '$s' }, + { op: 'add', kind: 'startEvent', name: 'Go', in: 'phase', as: '$s' }, { op: 'split', after: '$s', kind: 'parallelGateway', branches: [{ nodes: [{ kind: 'task', name: 'Phase 1 Lieferantenbestellungsübersicht prüfen', as: '$p1' }] }, { nodes: [{ kind: 'task', name: 'Phase 2 Lieferantenbestellungsübersicht prüfen', as: '$p2' }] }], joinAs: '$j' }, { op: 'add', kind: 'task', name: 'Zahlungseingangsbestätigungen abgleichen', after: '$j', as: '$z' }, { op: 'connect', source: '$p1', target: '$z' }, { op: 'connect', source: '$p2', target: '$z' }, ]); - expect(flowIds(phases.xml)).toEqual(expect.arrayContaining(['Flow_Phase1ToZahlungseingangsbestaetigungenAbgleichen', 'Flow_Phase2ToZahlungseingangsbestaetigungenAbgleichen'])); + expect(flowIds(phases.xml)).toEqual(expect.arrayContaining(['flow_phase1ToZahlungseingangsbestaetigungenAbgleichen', 'flow_phase2ToZahlungseingangsbestaetigungenAbgleichen'])); expect(phases.result.warnings.added.map((warning) => warning.code)).not.toContain('W_ID_SUFFIXED'); // where it would read like a suffix (a separated `_4` at the end of the id) it goes with the given-up word: the base // never reads like a suffixed id, and no suffix is skipped - const snakeXml = definitionsXml(` - Flow_check_order_to_ship_order - Flow_check_order_to_ship_orderFlow_ship_order_to_bill_order - Flow_ship_order_to_bill_order - - `); - const stemSnake = IdStyle.infer((await Doc.fromXml(snakeXml)).definitions); - expect(stemSnake.info().sequenceFlow.form).toBe('stemSnake'); + const stemSnake = IdStyle.of({ prefixes: { 'bpmn:Task': 'Task_', 'bpmn:SequenceFlow': 'Flow_' }, case: 'snake_case' }); // before: Flow_zahlungseingangsbestaetigungen_to_lieferantenbestellungen_4, whose _4 the suffixes then skipped const req = connectionRequest('bpmn:SequenceFlow', 'Task_zahlungseingangsbestaetigungen', 'Task_lieferantenbestellungen_4_augen_ab'); const base = 'Flow_zahlungseingangsbestaetigungen_to_lieferantenbestellungen'; @@ -471,61 +441,16 @@ describe('id quality: context once, length cap, transliteration', () => { given.push(id.slice(base.length)); } expect(given).toEqual(['', '_2', '_3', '_4', '_5']); - // nor does a cut leave a number right before a kept number tail (stufe_2_2 for stufe_2_pruefung_2, whose _2 the suffixes skipped) - const pair = await idFlows('_'); - for (const [target, last] of [['Task_freigabe_stufe_2_pruefung_2', 12], ['Task_lieferung_10_teil_10', 12]] as const) { - const ids = new IdRegistry(); - const suffixed: string[] = []; - for (let index = 0; index < last; index++) { - const { id } = pair.next(connectionRequest('bpmn:SequenceFlow', 'Task_zahlungseingangsbestaetigungen_pruefen', target), ids); - ids.claim(id); - expect(id.length, id).toBeLessThanOrEqual(MAX_ID); - suffixed.push(index ? /_(\d+)$/.exec(id)![1]! : ''); - } - expect(suffixed, target).toEqual(['', ...Array.from({ length: last - 1 }, (_unused, index) => String(index + 2))]); - } - expect(pair.derivedBase(connectionRequest('bpmn:SequenceFlow', 'Task_zahlungseingangsbestaetigungen_pruefen', 'Task_freigabe_stufe_2_pruefung_2'))).toBe('Flow_Task_zahlungseingangsbestaetigungen_pruefen_Task_freigabe_2'); }); - it('a cut flow to a target that ends in two numbers keeps both: x_2_2 never names x_2, and a flow skips no suffix', async () => { - const pair = await idFlows('_'); - const source = 'Task_zahlungseingangsbestaetigungen_pruefen_und_buchen'; - const registry = new IdRegistry(); - const flows = ['Task_zahlungseingang_2', 'Task_zahlungseingang_2_2', 'Task_zahlungseingang_2_2'].map((target) => { - const req = connectionRequest('bpmn:SequenceFlow', source, target); - const out = pair.next(req, registry); - registry.claim(out.id); - return { ...out, req }; - }); - // before: the flow to _2_2 wanted the id of the flow to _2 (its inner _2 given up) and took _2_2, the next _2_3, - // each with W_ID_SUFFIXED; now the source gives up more, inside its one word where nothing else is left - expect(flows.map((flowCase) => flowCase.id)).toEqual([ - 'Flow_Task_zahlungseingangsbestaetigungen_Task_zahlungseingang_2', - 'Flow_Task_zahlungseingangsbestaetigunge_Task_zahlungseingang_2_2', - 'Flow_Task_zahlungseingangsbestaetigun_Task_zahlungseingang_2_2_2', - ]); - expect(flows.map((flowCase) => flowCase.id === flowCase.base)).toEqual([true, true, false]); - for (const flowCase of flows) expect(pair.derivedBases(flowCase.req).some((derivedBase) => flowCase.id === derivedBase || (flowCase.id.startsWith(derivedBase) && /^_\d+$/.test(flowCase.id.slice(derivedBase.length)))), flowCase.id).toBe(true); - // a target x_2_2 behind a long source: _2 follows the base (before: the base was ..._und_Task_x_2_2, whose cut for - // _2 gave the base back, so the flows took _2_3, _2_4) - const ids = new IdRegistry(); - const given: string[] = []; - for (let index = 0; index < 3; index++) { - const { id } = pair.next(connectionRequest('bpmn:SequenceFlow', source, 'Task_x_2_2'), ids); - ids.claim(id); - given.push(id); - } - expect(given).toEqual(['Flow_Task_zahlungseingangsbestaetigungen_pruefen_Task_x_2_2', 'Flow_Task_zahlungseingangsbestaetigungen_pruefen_Task_x_2_2_2', 'Flow_Task_zahlungseingangsbestaetigungen_pruefen_Task_x_2_2_3']); - // only numbers before a number tail stay: a number a cut leaves at the end of an end without one still goes (as before) - expect(pair.derivedBase(connectionRequest('bpmn:SequenceFlow', 'Task_synchronisieren_10_of_und_protokollieren_bestellungen_eingegangen', 'Task_lieferantenbestellungsuebersicht_x'))).toBe('Flow_Task_synchronisieren_Task_lieferantenbestellungsuebersicht'); - expect(pair.derivedBase(connectionRequest('bpmn:SequenceFlow', 'Task_versenden_versenden_2_10_auftrag', 'Task_kunde_und_the_of_pruefen_rechnung_eingegangen'))).toBe('Flow_Task_versenden_versenden_2_10_Task_kunde_und_the_of_pruefen'); + it('an element body the cap ends on a number skips the suffix its cut gives back', async () => { // an element body that the 64-character cap ends on a number skips the suffix its cut gives back: a shorter cut would // read as another name's id (never _auftrag_2 for the second Auftrag 2 2, which pushed Auftrag 2 to _2_4, // nor _step_kunde_2 for the second Step Kunde Kunde 2 freigeben) const task = kindByName('task')!; const long = 'orderFulfilmentProcessTaskWithAVeryLongPrefixNameForTestingXYZ'; const repeated = async (prefix: string, names: string[]): Promise => { - const style = IdStyle.infer((await Doc.fromXml(definitionsXml(['check_order', 'ship_order', 'bill_order'].map((taskName) => ``).join('\n')))).definitions); + const style = IdStyle.of({ prefixes: { 'bpmn:Task': `${prefix}_` }, case: 'snake_case' }); const registry = new IdRegistry(); return names.map((name) => { const { id } = style.next(kindRequest(task, { name }), registry); @@ -539,180 +464,54 @@ describe('id quality: context once, length cap, transliteration', () => { }); it('#31: a camelCase end without its first word starts as camelSlug writes it', async () => { - // camel bodies, hashed flows: named flows with camelCase sources - const xml = definitionsXml(` - Flow_0a1b2c3 - Flow_0a1b2c3Flow_1x2y3z4 - Flow_1x2y3z4 - - `); - const style = IdStyle.infer((await Doc.fromXml(xml)).definitions); + const style = IdStyle.of(); const flow = (sourceLabel: string, targetLabel: string): string => style.derivedBase(connectionRequest('bpmn:SequenceFlow', 'Activity_A', 'Activity_B', { sourceLabel, targetLabel })); - expect(flow('URL PDF versenden', 'Ship')).toBe('Flow_urlPDFVersendenToShip'); + expect(flow('URL PDF versenden', 'Ship')).toBe('flow_urlPDFVersendenToShip'); // the ends begin alike: the source gives up its first word, and the rest reads as above (not urlpdfVersenden) // (the target's 40 characters end before the filler word: before ...PruefenUnd) - expect(flow('Lieferantenbestellungen URL PDF versenden', 'Lieferantenbestellungen prüfen und abschließen')).toBe('Flow_urlPDFVersendenToLieferantenbestellungenPruefen'); + expect(flow('Lieferantenbestellungen URL PDF versenden', 'Lieferantenbestellungen prüfen und abschließen')).toBe('flow_urlPDFVersendenToLieferantenbestellungenPruefen'); // also where camelSlug cut the name at 40 characters, so that the end is a run of its middle words - expect(flow('Lieferantenbestellungen URL PDF versenden und archivieren', 'Lieferantenbestellungen prüfen und abschließen')).toBe('Flow_urlPDFVersendenToLieferantenbestellungenPruefen'); - }); - - it('#31: a very long file prefix gives way to the bpmn-cli prefix rather than pass 64 characters', async () => { - const prefix = 'orderFulfilmentProcessTaskWithAVeryLongPrefixNameForTestin'; - const xml = definitionsXml(` - flow_goToCheckOrder - flow_goToCheckOrderflow_checkOrderToShipOrder - flow_checkOrderToShipOrder - - `); - // 0.6: ${prefix}_auftrag (66 characters) and ${prefix}_2 - const applied = await applyToXml(xml, [{ op: 'add', kind: 'task', name: 'Auftrag', after: `${prefix}_shipOrder`, as: '$a' }, { op: 'add', kind: 'task', name: 'Auftrag', after: '$a', as: '$b' }], { layout: false }); - expect(applied.result.aliases).toMatchObject({ $a: 'activity_auftrag', $b: 'activity_auftrag_2' }); - for (const id of ids(applied.xml)) if (!id.startsWith(`${prefix}_`)) expect(id.length, id).toBeLessThanOrEqual(MAX_ID); - // an id that names an anchor's kind stays within 64 too (a prefix of 50 characters) - const style = IdStyle.infer((await Doc.fromXml(xml.replaceAll(prefix, prefix.slice(0, 50)))).definitions); - const registry = new IdRegistry(); - const task = kindByName('task')!; - for (let index = 0; index < 3; index++) { - const { id } = style.next(kindRequest(task, { context: 'On 2', anchorKind: 'ServiceTask' }), registry); - registry.claim(id); - expect(id.length, id).toBeLessThanOrEqual(MAX_ID); - } - // a flow prefix (and a scope) that leaves the ends fewer than 16 characters gives way to a named flow with Flow_, in - // every flow form (before: stemTo _To, both ends empty; a scopedTo Flow__To of 65 characters) - const flowPrefix = 'sequenceFlowOfTheKundenauftragsabwicklungUndLieferantenbestellu'; - const tasks = ['check_stock', 'ship_goods', 'bill_customer']; - const forms: Array<[string, (source: string, target: string) => string, (word: string) => string]> = [ - ['stemTo', (source, target) => `${source}To${target.charAt(0).toUpperCase()}${target.slice(1)}`, (word) => word.replace(/_(\w)/g, (_m, letter: string) => letter.toUpperCase())], - ['stemSnake', (source, target) => `${source}_to_${target}`, (word) => word], - ['idPair', (source, target) => `Task_${source}_Task_${target}`, (word) => word], - ['idSnake', (source, target) => `Task_${source}_to_Task_${target}`, (word) => word], - ]; - for (const prefixLength of [48, 56, 63]) { - for (const [form, flowBody, body] of forms) { - const cutPrefix = flowPrefix.slice(0, prefixLength); - const xmlOf = (prefix: string): string => - definitionsXml( - tasks.map((word) => ``).join('\n') + - `` + - ``, - ); - const long = IdStyle.infer((await Doc.fromXml(xmlOf(cutPrefix))).definitions); - expect(long.info().sequenceFlow, `${form} ${prefixLength}`).toEqual({ form, prefix: cutPrefix }); - const req = connectionRequest('bpmn:SequenceFlow', `Task_${body('validate_order_lines')}`, `Task_${body('receive_incoming_payment')}`); - const id = long.derivedBase(req); - expect(id, `${form} ${prefixLength}`).toMatch(/^Flow_(validate|Validate)/); - expect(id.length).toBeLessThanOrEqual(MAX_ID); - // short ends fit beside a prefix of 48 characters at whole words: the prefix stays (before: Flow_ for every flow) - const short = long.derivedBase(connectionRequest('bpmn:SequenceFlow', `Task_${body('pack_it')}`, `Task_${body('ship_it')}`)); - // (idPair / idSnake: the ends' own prefixes leave their words no room) - const kept: Record = { stemTo: `${cutPrefix}_packItToShipIt`, stemSnake: `${cutPrefix}_pack_to_ship_it` }; - expect(short, `${form} ${prefixLength}`).toBe((prefixLength === 48 && kept[form]) || (form === 'stemTo' ? 'Flow_packItToShipIt' : 'Flow_pack_it_to_ship_it')); - } - } - const scope = 'KundenauftragsabwicklungUndLieferantenbestellungsprozessX'; - const scopedTask = (word: string): string => `task_${scope}_${word}`; - const scoped = definitionsXml(` - - - - - `); - const scopedApplied = await applyToXml(scoped, [{ op: 'connect', source: scopedTask('Validate'), target: scopedTask('Ship') }, { op: 'connect', source: scopedTask('Validate'), target: scopedTask('Ship') }], { layout: false }); - // named by the end words in the case of the file's bodies (Pascal_Snake: _Validate) - expect(flowIds(scopedApplied.xml).filter((id) => !id.includes(scope))).toEqual(['Flow_Validate_To_Ship', 'Flow_Validate_To_Ship_2']); + expect(flow('Lieferantenbestellungen URL PDF versenden und archivieren', 'Lieferantenbestellungen prüfen und abschließen')).toBe('flow_urlPDFVersendenToLieferantenbestellungenPruefen'); }); - it('a body keeps a letter in a small room; a file prefix over 52 characters stays where the body\'s first word with a letter fits whole beside it', async () => { + it('a body keeps a letter in a small room', () => { // a room that ends inside a leading number: the cut starts at the first word with a letter (before: 10202422024) expect(cutAt('10202422024Rechnung', 11)).toBe('Rechnung'); expect(cutAt('10202422024Rechnung', 6)).toBe('Rechnu'); expect(cutAt('2024_100_rechnung_pruefen', 9)).toBe('rechnung'); // a cut that keeps a letter is unchanged expect(cutAt('10202422024Rechnung', 12)).toBe('10202422024R'); - const task = kindByName('task')!; - const names = ['10202422024 Rechnung', '2024 100 Rechnung', '2024 100 Rechnung', 'Auftrag', 'Auftrag', 'Gh']; - const idsUnder = async (prefix: string): Promise => { - const style = IdStyle.infer((await Doc.fromXml(definitionsXml(['checkOrder', 'shipOrder', 'billOrder'].map((taskName) => ``).join('\n')))).definitions); - const registry = new IdRegistry(); - return names.map((name) => { - const { id } = style.next(kindRequest(task, { name }), registry); - registry.claim(id); - expect(id.length, id).toBeLessThanOrEqual(MAX_ID); - return id.startsWith(prefix) ? `${id.slice(prefix.length)}` : id; - }); - }; - const long = 'orderFulfilmentProcessTaskWithAVeryLongPrefixNameForTestingXYZ'; - // 52 characters (before: _10202422024, no letter); a camelCase body that lost its number begins in lower case - expect(await idsUnder(long.slice(0, 52))).toEqual(['_rechnung', '_2024100Rech', '_2024100Re_2', '_auftrag', '_auftrag_2', '_gh']); - // 53 (before: _1020242202, _2024100Rec, _2024100R_2) - expect(await idsUnder(long.slice(0, 53))).toEqual(['activity_10202422024Rechnung', 'activity_2024100Rechnung', 'activity_2024100Rechnung_2', '_auftrag', '_auftrag_2', '_gh']); - // 55 (before: _202410_2 without a letter, _auftra_2): the suffix leaves Auftrag no room beside the prefix - expect(await idsUnder(long.slice(0, 55))).toEqual(['activity_10202422024Rechnung', 'activity_2024100Rechnung', 'activity_2024100Rechnung_2', '_auftrag', 'activity_auftrag_2', '_gh']); - // 56 and more (before: activity_ for every id): the file's prefix where the id fits (Gh, 59 characters) - expect(await idsUnder(long.slice(0, 56))).toEqual(['activity_10202422024Rechnung', 'activity_2024100Rechnung', 'activity_2024100Rechnung_2', '_auftrag', 'activity_auftrag_2', '_gh']); - expect(await idsUnder(long.slice(0, 62))).toEqual(['activity_10202422024Rechnung', 'activity_2024100Rechnung', 'activity_2024100Rechnung_2', 'activity_auftrag', 'activity_auftrag_2', 'activity_gh']); - // a filler word is no first word to keep the prefix for (not _the) - const style = IdStyle.infer((await Doc.fromXml(definitionsXml(['checkOrder', 'shipOrder'].map((taskName) => ``).join('\n')))).definitions); - expect(style.derivedBase(kindRequest(task, { name: 'The archive' }))).toBe('activity_theArchive'); - // nor is the context word of an unnamed element: its id names the anchor (not _after, _after_2, ... - // with W_ID_SUFFIXED, from 56 characters on; before the bpmn-cli prefix also _after at 54 and 55) - const unnamed = async (prefixLength: number): Promise<{ ids: string[]; suffixed: number }> => { - const cutPrefix = long.slice(0, prefixLength); - const xml = definitionsXml(['checkOrder', 'shipOrder', 'billOrder'].map((taskName) => ``).join('\n')); - const applied = await applyToXml(xml, ['checkOrder', 'shipOrder', 'billOrder'].map((taskName) => ({ op: 'add', kind: 'task', after: `${cutPrefix}_${taskName}` })), { layout: false }); - const ids = applied.result.created.filter((created) => created.kind === 'task').map((created) => (created.id.startsWith(cutPrefix) ? `${created.id.slice(cutPrefix.length)}` : created.id)); - return { ids, suffixed: applied.result.warnings.added.filter((warning) => warning.code === 'W_ID_SUFFIXED').length }; - }; - for (const prefixLength of [55, 56, 58]) expect(await unnamed(prefixLength), String(prefixLength)).toEqual({ ids: ['activity_afterCheckOrder', 'activity_afterShipOrder', 'activity_afterBillOrder'], suffixed: 0 }); - // where the anchor's first word fits, the prefix stays, per id - expect(await unnamed(54)).toEqual({ ids: ['activity_afterCheckOrder', '_afterShip', '_afterBill'], suffixed: 0 }); - expect(await unnamed(53)).toEqual({ ids: ['_afterCheck', '_afterShip', '_afterBill'], suffixed: 0 }); }); - /** A style learned from a file whose flows are `Flow_` (idPair `_`, idSnake `_to_`). */ - async function idFlows(join: string): Promise { - const pairs = [['Task_a1', 'Task_a2'], ['Task_a2', 'Task_a3'], ['Task_a3', 'Task_a4']] as const; - const tasks = ['Task_a1', 'Task_a2', 'Task_a3', 'Task_a4'].map((id) => ``).join('\n'); - const flows = pairs.map(([source, target]) => ``).join('\n'); - return IdStyle.infer((await Doc.fromXml(definitionsXml(`${tasks}\n${flows}`))).definitions); - } - - it('#31: a cut keeps a Join and number tail and the first word before a number, the source gives up a word on a tie, a leading separator stays', async () => { - const flow = (source: string, target: string, labels: { sourceLabel?: string; targetLabel?: string } = {}): string => IdStyle.DEFAULT.derivedBase(connectionRequest('bpmn:SequenceFlow', source, target, labels)); + it('#31: a cut keeps a Join and number tail and the first word before a number, the source gives up a word on a tie', async () => { + const flow = (source: string, target: string, labels: { sourceLabel?: string; targetLabel?: string } = {}): string => IdStyle.of().derivedBase(connectionRequest('bpmn:SequenceFlow', source, target, labels)); // ends that begin alike: the join of a split and its number stay whole, the word before them goes - expect(flow('Gateway_LieferantenbestellungenPruefenJoin_2', 'Activity_LieferantenbestellungenArchivierenSofort')).toBe('Flow_LieferantenbestellungenJoin2ToLieferantenbestellungenSofort'); + expect(flow('Gateway_LieferantenbestellungenPruefenJoin_2', 'Activity_LieferantenbestellungenArchivierenSofort')).toBe('flow_lieferantenbestellungenJoin2ToLieferantenbestellungenSofort'); // the Join is no word with a letter the end could keep instead of its last one: never a bare Join (before this fix: - // Flow_JoinToLieferantenbestellungenBuchenUndAnDie, Flow_2JoinToWarenwirtschaftssystemabgleich) + // flow_joinToLieferantenbestellungenBuchenUndAnDie, flow_2JoinToWarenwirtschaftssystemabgleich) const joined = await applyToXml((await newXml({ processName: 'J' })).xml, [ - { op: 'add', kind: 'startEvent', name: 'Go', in: 'Process_J', as: '$s' }, + { op: 'add', kind: 'startEvent', name: 'Go', in: 'j', as: '$s' }, { op: 'add', kind: 'task', name: 'Lieferantenbestellungen prüfen', after: '$s', as: '$t' }, { op: 'split', after: '$t', kind: 'parallelGateway', branches: [{ nodes: [{ kind: 'task', name: 'A' }] }, { nodes: [{ kind: 'task', name: 'B' }] }], joinAs: '$j' }, { op: 'add', kind: 'task', name: 'Lieferantenbestellungen buchen lassen und melden', after: '$j' }, ]); // (the target's 40 characters end before the filler word: LieferantenbestellungenBuchenLassen, before ...LassenUnd) - expect(flowIds(joined.xml)).toContain('Flow_LieferantenbestellungenJoinToLieferantenbestellungenLassen'); - expect(flow('Activity_A', 'Activity_B', { sourceLabel: '2 Lieferantenbestellungen Join', targetLabel: 'Warenwirtschaftssystemabgleich' })).toBe('Flow_2LieferantenbestellungenJoinToWarenwirtschaftssystemabgleic'); + expect(flowIds(joined.xml)).toContain('flow_lieferantenbestellungenJoinToLieferantenbestellungenLassen'); + expect(flow('Activity_A', 'Activity_B', { sourceLabel: '2 Lieferantenbestellungen Join', targetLabel: 'Warenwirtschaftssystemabgleich' })).toBe('flow_from2LieferantenbestellungenJoinToWarenwirtschaftssystemabg'); // an end never shrinks to a number: its first word stays, the other end gives up its own - expect(flow('Activity_A', 'Activity_B', { sourceLabel: 'Lieferantenbestellungsabwicklung 10', targetLabel: 'Lieferantenbestellungsabwicklung ok' })).toBe('Flow_Lieferantenbestellungsabwicklung10ToOk'); + expect(flow('Activity_A', 'Activity_B', { sourceLabel: 'Lieferantenbestellungsabwicklung 10', targetLabel: 'Lieferantenbestellungsabwicklung ok' })).toBe('flow_lieferantenbestellungsabwicklung10ToOk'); // ends of 29 characters each, one word too long: the source gives it up - expect(flow('Activity_A', 'Activity_B', { sourceLabel: 'Eingangsrechnung pruefen lassen', targetLabel: 'Lieferschein scannen und ablegen' })).toBe('Flow_EingangsrechnungPruefenToLieferscheinScannenUndAblegen'); - // an end whose words begin with a separator (Task__eingangsrechnung...) keeps it when it gives up a later word - const style = await idFlows('_'); - expect(style.derivedBase(connectionRequest('bpmn:SequenceFlow', 'Task_a1', 'Task_a2'))).toBe('Flow_Task_a1_Task_a2'); - expect(style.derivedBase(connectionRequest('bpmn:SequenceFlow', 'Task__eingangsrechnung_pruefen_lassen', 'Task__lieferschein_scannen_ablegen'))).toBe('Flow_Task__eingangsrechnung_pruefen_Task__lieferschein_scannen'); + expect(flow('Activity_A', 'Activity_B', { sourceLabel: 'Eingangsrechnung pruefen lassen', targetLabel: 'Lieferschein scannen und ablegen' })).toBe('flow_eingangsrechnungPruefenToLieferscheinScannenUndAblegen'); }); it('a cut never ends on a filler word (und, an, die): the ends of a flow and the 40 characters of a name give it up too, never the first word', async () => { - const flow = (sourceLabel: string, targetLabel: string): string => IdStyle.DEFAULT.derivedBase(connectionRequest('bpmn:SequenceFlow', 'Activity_A', 'Activity_B', { sourceLabel, targetLabel })); - // before: Flow_LieferungVersendenUndToRechnungEingegangenUndZahlungPruefen - expect(flow('Lieferung versenden und Kunde informieren', 'Rechnung eingegangen und Zahlung prüfen')).toBe('Flow_LieferungVersendenUndKundeToRechnungEingegangenUndZahlung'); - // before: Flow_LieferungAnKundenToRechnungAnBuchhaltungWeiterleitenUnd - expect(flow('Lieferung an Kunden versenden', 'Rechnung an Buchhaltung weiterleiten und prüfen')).toBe('Flow_LieferungAnKundenToRechnungAnBuchhaltungWeiterleiten'); + const flow = (sourceLabel: string, targetLabel: string): string => IdStyle.of().derivedBase(connectionRequest('bpmn:SequenceFlow', 'Activity_A', 'Activity_B', { sourceLabel, targetLabel })); + // before: flow_lieferungVersendenUndToRechnungEingegangenUndZahlungPruefen + expect(flow('Lieferung versenden und Kunde informieren', 'Rechnung eingegangen und Zahlung prüfen')).toBe('flow_lieferungVersendenUndKundeToRechnungEingegangenUndZahlung'); + // before: flow_lieferungAnKundenToRechnungAnBuchhaltungWeiterleitenUnd + expect(flow('Lieferung an Kunden versenden', 'Rechnung an Buchhaltung weiterleiten und prüfen')).toBe('flow_lieferungAnKundenToRechnungAnBuchhaltungWeiterleiten'); // a name that ends on one is the author's: an end that is not cut keeps it - expect(flow('Ware prüfen und', 'Lieferantenbestellungsübersicht aktualisieren oder abschließen')).toBe('Flow_WarePruefenUndToLieferantenbestellungsuebersicht'); - // idPair (before: Flow_Task_lieferung_versenden_und_Task_rechnung_eingegangen_und): of the cuts that fit, the most words - const pair = await idFlows('_'); - expect(pair.derivedBase(connectionRequest('bpmn:SequenceFlow', 'Task_lieferung_versenden_und_pruefen', 'Task_rechnung_eingegangen_und_abgleichen'))).toBe('Flow_Task_lieferung_Task_rechnung_eingegangen_und_abgleichen'); + expect(flow('Ware prüfen und', 'Lieferantenbestellungsübersicht aktualisieren oder abschließen')).toBe('flow_warePruefenUndToLieferantenbestellungsuebersicht'); // the 40 characters of a name (before: LieferantenbestellungVersendenUnd), also before a number tail, in every case expect(slugify('Lieferantenbestellung versenden und Zahlungseingang prüfen')).toBe('LieferantenbestellungVersenden'); expect(slugify('Lieferantenbestellungen prüfen und an Buchhaltung 2')).toBe('LieferantenbestellungenPruefen2'); @@ -724,189 +523,17 @@ describe('id quality: context once, length cap, transliteration', () => { expect(cutName('UndLieferantenbestellungsuebersichtsseiten', 10)).toBe('Und'); expect(cutAt('Lieferantenbestellungen_pruefen_und_buchen', 35)).toBe('Lieferantenbestellungen_pruefen_und'); const applied = await applyToXml((await newXml({ processName: 'Filler' })).xml, [ - { op: 'add', kind: 'startEvent', name: 'Go', in: 'Process_Filler', as: '$s' }, + { op: 'add', kind: 'startEvent', name: 'Go', in: 'filler', as: '$s' }, { op: 'add', kind: 'task', name: 'Lieferantenbestellung versenden und Zahlungseingang prüfen', after: '$s', as: '$t' }, ], { layout: false }); - expect(applied.result.aliases).toMatchObject({ $t: 'Activity_LieferantenbestellungVersenden' }); - expect(flowIds(applied.xml)).toEqual(['Flow_GoToLieferantenbestellungVersenden']); - }); - - it("#31: a flow named by its ends' ids keeps each id's prefix, the words after it share the room", async () => { - const pair = await idFlows('_'); - // other prefixes, words that begin alike (the unnamed elements after one anchor): each end keeps its last word - expect(pair.derivedBase(connectionRequest('bpmn:SequenceFlow', 'Gateway_after_check_order_and_invoice', 'Task_after_check_order_and_invoice_gateway'))).toBe('Flow_Gateway_after_check_invoice_Task_after_check_order_gateway'); - // random ends behind Task_, Gateway_ and Event_ (a third begin alike): every id within 64 characters, the uncut one - // where it fits, else each end behind its own prefix with whole words of the ids, and the same id every time - const vocabulary = ['auftrag', 'pruefen', 'lieferung', 'synchronisieren', 'bestellungen', 'rechnung', 'freigeben', 'kunde', 'protokollieren', 'eingegangen', 'versenden', 'und', 'an', 'archivieren', '2', '10']; - const prefixes = ['Task', 'Gateway', 'Event']; - let seed = 31; - const pick = (bound: number): number => { - seed = (Math.imul(seed, 1103515245) + 12345) >>> 0; - return (seed >>> 16) % bound; - }; - const name = (head?: string): string => { - const words = [head ?? vocabulary[pick(vocabulary.length - 2)]!]; - for (let remaining = pick(6); remaining > 0; remaining--) words.push(vocabulary[pick(vocabulary.length)]!); - return snakeSlug(words.join(' ')); - }; - for (const [join, style] of [['_', pair], ['_to_', await idFlows('_to_')]] as const) { - expect(style.info().sequenceFlow.form).toBe(join === '_' ? 'idPair' : 'idSnake'); - for (let index = 0; index < 1000; index++) { - const [sp, tp] = [prefixes[pick(3)]!, prefixes[pick(3)]!]; - const sourceName = name(); - const [source, target] = [`${sp}_${sourceName}`, `${tp}_${pick(3) === 0 ? name(sourceName.split('_')[0]) : name()}`]; - const id = style.derivedBase(connectionRequest('bpmn:SequenceFlow', source, target)); - const uncut = `Flow_${source}${join}${target}`; - expect(id.length, id).toBeLessThanOrEqual(MAX_ID); - if (uncut.length <= MAX_ID) expect(id).toBe(uncut); - else { - const match = new RegExp(`^Flow_${sp}_([a-z0-9_]+)${join}${tp}_([a-z0-9_]+)$`).exec(id); - expect(match, id).not.toBeNull(); - const known = new Set([...idWords(source), ...idWords(target)].map((word) => word.replace(/^_/, ''))); - for (const word of idWords(`${match![1]}_${match![2]}`)) expect(known, `${word} in ${id}`).toContain(word.replace(/^_/, '')); - } - expect(style.derivedBase(connectionRequest('bpmn:SequenceFlow', source, target))).toBe(id); - } - } - }); - - it('#31: flows the cap cut still vote for their form: a file of idPair flows keeps it, and every splice renames its flow', async () => { - const nodes = [ - ['startEvent', 'Event_Start', 'Start'], - ['task', 'Activity_CheckOrder', 'Check order'], - ['task', 'Activity_ShipOrder', 'Ship order'], - ['endEvent', 'Event_Done', 'Done'], - ] as const; - const flow = (index: number): string => `Flow_${nodes[index]![1]}_${nodes[index + 1]![1]}`; - let xml = definitionsXml( - nodes.map(([tag, id, name], index) => `${index ? `${flow(index - 1)}` : ''}${index < 3 ? `${flow(index)}` : ''}`).join('\n') + - [0, 1, 2].map((index) => ``).join('\n'), - ); - const checkPayments = 'Activity_ZahlungseingaengePruefenUndAbgleichen'; - const steps: Array<[string, string]> = [ - ['Zahlungseingänge prüfen und abgleichen', 'Activity_ShipOrder'], - ...['Lieferantenbestellungen archivieren', 'Rechnung buchen', 'Lieferschein drucken', 'Kundenstammdaten aktualisieren', 'Bestellung stornieren', 'Mahnung versenden'].map((name): [string, string] => [name, checkPayments]), - ]; - for (const [name, after] of steps) { - // before: the 7th splice found the style flipped to named (5 of 9 flows cut), renamed nothing and named its flow - // Flow_MahnungVersendenToBestellungStornieren - expect((await Doc.fromXml(xml)).ownIdStyle.info().sequenceFlow, name).toEqual({ form: 'idPair', prefix: 'Flow' }); - const applied = await applyToXml(xml, [{ op: 'add', kind: 'task', name, after }], { layout: false }); - expect(Object.keys(applied.result.renamed ?? {}), name).toHaveLength(1); - xml = applied.xml; - } - expect(flowIds(xml)).toEqual(expect.arrayContaining(['Flow_Activity_ZahlungseingaengePruefen_Activity_MahnungVersenden', 'Flow_Activity_MahnungVersenden_Activity_BestellungStornieren'])); - for (const id of flowIds(xml)) expect(id.length, id).toBeLessThanOrEqual(MAX_ID); - // a camelCase file whose flows read as stemTo (the cut ones now too) still follows the `named` flows bpmn-cli gave it - // before: a flow to a join is named by the join's label (CheckOrderJoin), not by its id stem - const camel = definitionsXml(` - Flow_checkOrderToShipOrder - Flow_checkOrderToShipOrderFlow_shipOrderToBillOrder - Flow_shipOrderToBillOrderFlow_billOrderToCheckOrderJoin - Flow_billOrderToCheckOrderJoin - - - `); - expect((await Doc.fromXml(camel)).ownIdStyle.info().sequenceFlow).toEqual({ form: 'stemTo', prefix: 'Flow' }); - const spliced = await applyToXml(camel, [{ op: 'add', kind: 'task', name: 'Archive order', flow: 'Flow_billOrderToCheckOrderJoin' }], { layout: false }); - expect(spliced.result.renamed).toEqual({ Flow_billOrderToCheckOrderJoin: 'Flow_billOrderToArchiveOrder' }); - }); - - it('#31: flows 0.6 named still follow their ends where the file now reads as another form', async () => { - /** a chain of tasks ([id, name]) joined by the given flows */ - const chainXml = (tasks: Array<[string, string]>, flows: string[]): string => - definitionsXml( - tasks.map(([id, name], index) => `${index ? `${flows[index - 1]}` : ''}${index < flows.length ? `${flows[index]}` : ''}`).join('\n') + - flows.map((id, index) => ``).join('\n'), - ); - // an idSnake file with a flow 0.6 named (Flow_pay_to_done) - const idSnake = chainXml( - [['Task_check', 'Check'], ['Task_ship', 'Ship'], ['Task_bill', 'Bill'], ['Task_pay', 'Pay'], ['Task_done', 'Done']], - ['Flow_Task_check_to_Task_ship', 'Flow_Task_ship_to_Task_bill', 'Flow_Task_bill_to_Task_pay', 'Flow_pay_to_done'], - ); - expect((await Doc.fromXml(idSnake)).ownIdStyle.info().sequenceFlow).toEqual({ form: 'idSnake', prefix: 'Flow' }); - const log = await applyToXml(idSnake, [{ op: 'add', kind: 'task', name: 'Log', flow: 'Flow_pay_to_done' }], { layout: false }); - expect(log.result.renamed).toEqual({ Flow_pay_to_done: 'Flow_Task_pay_to_Task_log' }); - // an id an author wrote in the `named` form follows too: nothing tells it from one bpmn-cli wrote (ID_CONVENTIONS.renames) - const handWritten = definitionsXml(` - flow_eventOrderReceivedToCheckOrder - flow_eventOrderReceivedToCheckOrderflow_checkOrderToShipOrder - flow_checkOrderToShipOrderflow_shipOrderToOrderShipped - flow_shipOrderToOrderShipped - - - `); - expect((await Doc.fromXml(handWritten)).ownIdStyle.info().sequenceFlow).toEqual({ form: 'stemTo', prefix: 'flow' }); - const gate = await applyToXml(handWritten, [{ op: 'add', kind: 'serviceTask', name: 'Check gate', after: 'start_event_order_received' }], { layout: false }); - expect(gate.result.renamed).toEqual({ flow_eventOrderReceivedToCheckOrder: 'flow_event_order_receivedToCheckGate' }); - expect(ID_CONVENTIONS.renames).toMatch(/or the one any other flow prefix or form the file's flows use gives it, also the named Flow_To id in the file's case, which earlier versions gave its flows, whoever wrote it/); - }); - - it('#31: a long head stays where the ends fit beside it at whole words, Flow_ elsewhere; a flow 0.6 named with it still follows its ends', async () => { - // a scope of 41 characters leaves the ends fewer than 16: new flows keep it where the end words fit beside it (before: - // Flow_Validate_To_Pack for every new flow), else Flow__To_; a flow with the scope still names its ends - // (before #31: the 65-character flow below kept its id on the flow to Pack) - const scope = 'KundenauftragsabwicklungLieferantenpflege'; - const task = (word: string): string => `serviceTask_${scope}_${word}`; - const xml = definitionsXml(` - Flow_${scope}_ValidateToShipping - Flow_${scope}_ValidateToShippingFlow_${scope}_ShippingToInvoicing - Flow_${scope}_ShippingToInvoicing - - `); - expect((await Doc.fromXml(xml)).ownIdStyle.info().sequenceFlow).toEqual({ form: 'scopedTo', prefix: 'Flow', scope }); - const applied = await applyToXml(xml, [{ op: 'add', kind: 'serviceTask', name: 'Pack', after: task('Validate') }], { layout: false }); - expect(applied.result.renamed).toEqual({ [`Flow_${scope}_ValidateToShipping`]: `Flow_${scope}_ValidateToPack` }); - expect(flowIds(applied.xml)).toContain(`Flow_${scope}_PackToShipping`); - // end words that do not fit beside the scope: a named flow with Flow_, by its end words - const long = await applyToXml(applied.xml, [ - { op: 'add', kind: 'serviceTask', id: task('Warenwirtschaftssystemabgleich'), name: 'Warenwirtschaftssystemabgleich', in: 'Process_1' }, - { op: 'connect', source: task('Validate'), target: task('Warenwirtschaftssystemabgleich') }, - ], { layout: false }); - expect(flowIds(long.xml)).toContain('Flow_Validate_To_Warenwirtschaftssystemabgleich'); - // both follow their ends - const spliced = await applyToXml(long.xml, [ - { op: 'add', kind: 'serviceTask', name: 'Weigh', flow: `Flow_${scope}_PackToShipping` }, - { op: 'add', kind: 'serviceTask', name: 'Check', flow: 'Flow_Validate_To_Warenwirtschaftssystemabgleich' }, - ], { layout: false }); - expect(spliced.result.renamed).toEqual({ [`Flow_${scope}_PackToShipping`]: `Flow_${scope}_PackToWeigh`, Flow_Validate_To_Warenwirtschaftssystemabgleich: `Flow_${scope}_ValidateToCheck` }); - // the conventions view names the fallback - const view = conventionsView(await Doc.fromXml(xml), undefined).rows.find((row) => row.kinds.includes('sequenceFlow')); - expect(view).toMatchObject({ pattern: `Flow_${scope}_{SourceWord}To{TargetWord}`, note: 'Flow_ where the ends do not fit' }); - // a suffix that leaves the end words no room beside a 48-character scope: the named flow's `_2`, not the scoped glued 2 - const wide = `${scope}Abcdefg`; - const style = IdStyle.infer((await Doc.fromXml(xml.replaceAll(scope, wide))).definitions); - const registry = new IdRegistry(); - const given = [0, 1, 2].map(() => { - const { id } = style.next(connectionRequest('bpmn:SequenceFlow', `serviceTask_${wide}_Pack`, `serviceTask_${wide}_Ship`), registry); - registry.claim(id); - return id; - }); - expect(given).toEqual([`Flow_${wide}_PackToShip`, 'Flow_Pack_To_Ship_2', 'Flow_Pack_To_Ship_3']); - expect(given[0]).toHaveLength(MAX_ID); - // a base that ends in a digit, whose No2 leaves the end words no room beside the 41-character scope: the whole id - // takes the named flow's form with its _2 (before: Flow__ValidateToStep1_2, a form no file reads back) - const steps = await applyToXml(xml, [ - { op: 'add', kind: 'serviceTask', id: task('Step1_Check'), name: 'Step 1 check', in: 'Process_1' }, - { op: 'add', kind: 'serviceTask', id: task('Step1_Ship'), name: 'Step 1 ship', in: 'Process_1' }, - { op: 'connect', source: task('Validate'), target: task('Step1_Check') }, - { op: 'connect', source: task('Validate'), target: task('Step1_Ship') }, - ], { layout: false }); - expect(flowIds(steps.xml).filter((id) => id.includes('Step1'))).toEqual([`Flow_${scope}_ValidateToStep1`, 'Flow_Validate_To_Step1_2']); - // the same in a stemTo file with a 46-character prefix (before: _stepToRechnung9_2) - const lp = 'sequenceFlowOfTheKundenauftragsabwicklungUndLi'; - const stem = definitionsXml( - ['checkOrder', 'shipOrder', 'billOrder', 'step', 'rechnung9'].map((id) => ``).join('\n') + - `` + - ``, - ); - const stemmed = await applyToXml(stem, [0, 1, 2].map(() => ({ op: 'connect', source: 'task_step', target: 'task_rechnung9' })), { layout: false }); - expect(flowIds(stemmed.xml).filter((id) => id.includes('Rechnung9'))).toEqual([`${lp}_stepToRechnung9`, 'Flow_stepToRechnung9_2', 'Flow_stepToRechnung9_3']); + expect(applied.result.aliases).toMatchObject({ $t: 'task_lieferantenbestellungVersenden' }); + expect(flowIds(applied.xml)).toEqual(['flow_goToLieferantenbestellungVersenden']); }); it('#31: a suffix that cuts an anchor-qualified id again keeps the anchor kind', async () => { - // a task prefix of 26 characters: the form that names the gateway has 64, its _2 is cut again + // a declared task prefix of 27 characters: the form that names the gateway has 64, its suffix cuts it again const prefix = 'orderFulfilmentProcessTask'; + const conventions = { prefixes: { 'bpmn:Task': `${prefix}_` }, case: 'camelCase' } as const; const xml = definitionsXml(` flow_goToCheckOrder flow_goToCheckOrderflow_checkOrderToShipOrder @@ -919,24 +546,17 @@ describe('id quality: context once, length cap, transliteration', () => { { op: 'add', kind: 'task', name: 'Lieferung pruefen Abcdefghi', after: `${prefix}_shipOrder`, as: '$l' }, { op: 'split', after: '$l', kind: 'exclusiveGateway', branches: [{ nodes: [{ kind: 'task', as: '$a' }] }, { nodes: [{ kind: 'task', as: '$b' }] }] }, ], - { layout: false }, + { layout: false, conventions }, ); - // without the anchor's kind the _2 would read like the gateway (..._afterLieferungPruefenAbcdefghi_2) - expect(applied.result.aliases).toMatchObject({ $a: `${prefix}_afterLieferungPruefenAbcdefghiGateway`, $b: `${prefix}_afterLieferungPruefenGateway_2` }); + // without the anchor's kind the 2 would read like the gateway (..._afterLieferungPruefenAbcdefghi2) + expect(applied.result.aliases).toMatchObject({ $a: `${prefix}_afterLieferungPruefenAbcdefghiGateway`, $b: `${prefix}_afterLieferungPruefenGateway2` }); expect(applied.result.aliases!['$a']).toHaveLength(MAX_ID); for (const id of ids(applied.xml)) expect(id.length).toBeLessThanOrEqual(MAX_ID); for (const id of flowIds(applied.xml)) expect(sameEnds(id), id).toBe(false); }); it('#31: ends of any length are cut fast: only the forms that can fit are listed', async () => { - const xml = definitionsXml(` - - - - - `); - const style = IdStyle.infer((await Doc.fromXml(xml)).definitions); - expect(style.info().sequenceFlow.form).toBe('stemSnake'); + const style = IdStyle.of({ prefixes: { 'bpmn:Task': 'Task_', 'bpmn:SequenceFlow': 'Flow_' }, case: 'snake_case' }); // ids of 1,600 short words (4,800 characters): before, every form of each end was listed and paired (256 ms of CPU) const words = (prefixLetter: string): string => Array.from({ length: 1600 }, (_unused, index) => `${prefixLetter}${String.fromCharCode(97 + (index % 26))}`).join('_'); const req = connectionRequest('bpmn:SequenceFlow', `Task_${words('a')}`, `Task_${words('b')}`); @@ -954,32 +574,32 @@ describe('id quality: context once, length cap, transliteration', () => { it('#31: a collision suffix fits into 64 characters: the ends are cut again at whole words, and the flow follows its ends', async () => { const applied = await applyToXml((await newXml({ processName: 'Sync' })).xml, [ - { op: 'add', kind: 'startEvent', name: 'Go', in: 'Process_Sync', as: '$s' }, + { op: 'add', kind: 'startEvent', name: 'Go', in: 'sync', as: '$s' }, { op: 'add', kind: 'exclusiveGateway', name: 'Deliveriesabcd synchronisieren', after: '$s', as: '$g' }, { op: 'add', kind: 'serviceTask', name: 'Deliveriesabcd protokollieren', after: '$g', as: '$t' }, { op: 'connect', source: '$g', target: '$t', as: '$f' }, ]); const suffixed = applied.result.aliases!['$f']!; - expect(suffixed).toBe('Flow_SynchronisierenToDeliveriesabcdProtokollieren_2'); - expect(applied.result.warnings.added.find((warning) => warning.code === 'W_ID_SUFFIXED')?.message).toBe(`Id Flow_DeliveriesabcdSynchronisierenToDeliveriesabcdProtokollieren is already taken; using ${suffixed}`); + expect(suffixed).toBe('flow_synchronisierenToDeliveriesabcdProtokollieren2'); + expect(applied.result.warnings.added.find((warning) => warning.code === 'W_ID_SUFFIXED')?.message).toBe(`Id flow_deliveriesabcdSynchronisierenToDeliveriesabcdProtokollieren is already taken; using ${suffixed}`); const r2 = await applyToXml(applied.xml, [{ op: 'add', kind: 'task', name: 'Pruefen', flow: suffixed }]); - expect(r2.result.renamed).toEqual({ [suffixed]: 'Flow_DeliveriesabcdSynchronisierenToPruefen' }); + expect(r2.result.renamed).toEqual({ [suffixed]: 'flow_deliveriesabcdSynchronisierenToPruefen' }); // every suffix up to _101 stays within 64, and each suffixed id is recognised as named after its ends const req = connectionRequest('bpmn:SequenceFlow', 'Gateway_DeliveriesabcdSynchronisieren', 'Activity_DeliveriesabcdProtokollieren'); const registry = new IdRegistry(); const given: string[] = []; for (let index = 0; index < 101; index++) { - const { id } = IdStyle.DEFAULT.next(req, registry); + const { id } = IdStyle.of().next(req, registry); registry.claim(id); given.push(id); } - expect(given.slice(98)).toEqual(['Flow_SynchronisierenToDeliveriesabcdProtokollieren_99', 'Flow_SynchronisierenToDeliveriesabcdProtokollieren_100', 'Flow_SynchronisierenToDeliveriesabcdProtokollieren_101']); + expect(given.slice(98)).toEqual(['flow_synchronisierenToDeliveriesabcdProtokollieren99', 'flow_synchronisierenToDeliveriesabcdProtokollieren100', 'flow_synchronisierenToDeliveriesabcdProtokollieren101']); expect(given.filter((id) => id.length > MAX_ID)).toEqual([]); - const bases = IdStyle.DEFAULT.derivedBases(req); - for (const id of given) expect(bases.some((base) => id === base || (id.startsWith(base) && /^_\d+$/.test(id.slice(base.length)))), id).toBe(true); + const bases = IdStyle.of().derivedBases(req); + for (const id of given) expect(bases.some((base) => id === base || (id.startsWith(base) && /^\d+$/.test(id.slice(base.length)))), id).toBe(true); }); - it('#31: a stemTo flow keeps its suffix without a separator when the suffix cuts it again', async () => { + it('#31: a flow keeps its suffix without a separator when the suffix cuts it again', async () => { const gateway = 'exclusiveGateway_deliveriesabcdSynchronisieren'; const task = 'serviceTask_deliveriesabcdProtokollieren'; const flow = 'flow_deliveriesabcdSynchronisierenToDeliveriesabcdProtokollieren'; @@ -996,7 +616,7 @@ describe('id quality: context once, length cap, transliteration', () => { expect(r2.result.renamed).toEqual({ flow_synchronisierenToDeliveriesabcdProtokollieren2: 'flow_deliveriesabcdSynchronisierenToPruefen' }); }); - it('a cut stemTo flow to an end that ends in a digit takes No before its suffix, still votes for the form and follows its ends', async () => { + it('a cut flow to an end that ends in a digit takes No before its suffix and follows its ends', async () => { const gateway = 'exclusiveGateway_lieferantenbestellungSynchronisieren'; const task = 'serviceTask_zahlungseingangProtokollieren10'; const xml = definitionsXml(` @@ -1012,13 +632,12 @@ describe('id quality: context once, length cap, transliteration', () => { const base = 'flow_lieferantenbestellungSynchronisierenToZahlungseingang10'; expect(Object.values(applied.result.aliases!)).toEqual([base, `${base}No2`, `${base}No3`, `${base}No4`]); expect(`${base}No2`).toHaveLength(63); - // four of the six flows are cut: they vote for stemTo (cutOfForm), and a splice renames the suffixed one - expect((await Doc.fromXml(applied.xml)).ownIdStyle.info().sequenceFlow).toEqual({ form: 'stemTo', prefix: 'flow' }); + // a splice renames the suffixed one const r2 = await applyToXml(applied.xml, [{ op: 'add', kind: 'task', name: 'Pruefen', flow: `${base}No3` }], { layout: false }); expect(r2.result.renamed).toEqual({ [`${base}No3`]: 'flow_lieferantenbestellungSynchronisierenToPruefen' }); }); - it('#31: element ids of 62 to 64 characters are not cut; the --if-absent hint and a data object get the same cap', async () => { + it('#31: element ids of 62 to 64 characters are not cut; the --if-absent hint gets the same cap', async () => { const typed = definitionsXml(` flow_goToCheckOrder flow_goToCheckOrderflow_checkOrderToShipOrder @@ -1027,14 +646,16 @@ describe('id quality: context once, length cap, transliteration', () => { `); + const conventions = { prefixes: { 'bpmn:IntermediateThrowEvent': 'intermediateThrowEvent_' }, case: 'camelCase' } as const; const event = { op: 'add', kind: 'intermediateThrowEvent', name: 'Lieferung vollständig eingegangen Abcdefgh' } as const; const full = 'intermediateThrowEvent_lieferungVollstaendigEingegangenAbcdefgh'; expect(full).toHaveLength(63); - // 0.6: intermediateThrowEvent_lieferungVollstaendigEingegangen; a second one takes a suffix, cut again at whole words - const applied = await applyToXml(typed, [{ ...event, after: 'serviceTask_shipOrder', as: '$e' }, { ...event, after: '$e', as: '$e2' }], { layout: false }); - expect(applied.result.aliases).toMatchObject({ $e: full, $e2: 'intermediateThrowEvent_lieferungVollstaendigEingegangen_2' }); + // 0.6: intermediateThrowEvent_lieferungVollstaendigEingegangen; a second one takes the glued suffix, which still fits + const applied = await applyToXml(typed, [{ ...event, after: 'serviceTask_shipOrder', as: '$e' }, { ...event, after: '$e', as: '$e2' }], { layout: false, conventions }); + expect(applied.result.aliases).toMatchObject({ $e: full, $e2: 'intermediateThrowEvent_lieferungVollstaendigEingegangenAbcdefgh2' }); // the id --if-absent asks for is the one add generates const doc = await Doc.fromXml(typed); + doc.conventions = conventions; let hint: string | undefined; try { runOps(doc, [{ ...event, after: 'serviceTask_shipOrder', ifAbsent: true }]); @@ -1042,89 +663,20 @@ describe('id quality: context once, length cap, transliteration', () => { hint = (error as { details?: { hint?: string } }).details?.hint; } expect(hint).toContain(`--id ${full} `); - // a data object whose id says what it is fits into 62 characters (0.6 cut the word Object and took _2) - const prefix = 'processDataArtefactItem'; - const withData = typed.replace( - ' - - - - warning.code)).not.toContain('W_ID_SUFFIXED'); - }); - - it('a data object or store whose reference has a long body keeps its Object / Store word after the body: no _2, no W_ID_SUFFIXED', async () => { - // references and backing elements under one prefix (snake bodies): the name's 40 characters had no room for the word - const prefix = 'processDataArtefactItem'; - const xml = ` - - - - - - - - - -`; - const cases: Array<[string, string, string, string]> = [ - // before: ..._lieferung_vollstaendig_eingegangen_2 (W_ID_SUFFIXED), from a body of 34 characters on - ['dataObjectReference', 'Lieferung vollständig eingegangen', 'lieferung_vollstaendig_eingegangen', 'lieferung_vollstaendig_object'], - ['dataObjectReference', 'Lieferantenbestellungen vollständig', 'lieferantenbestellungen_vollstaendig', 'lieferantenbestellungen_object'], - ['dataStoreReference', 'Lieferantenbestellungen vollständig', 'lieferantenbestellungen_vollstaendig', 'lieferantenbestellungen_store'], - // a short name: the word as before - ['dataObjectReference', 'Rechnung', 'rechnung', 'rechnung_object'], - ['dataStoreReference', 'Belege', 'belege', 'belege_store'], - ]; - for (const [kind, name, ref, backing] of cases) { - const applied = await applyToXml(xml, [{ op: 'add', kind, name, in: 'Process_1', as: '$d' }], { layout: false }); - expect(applied.result.aliases).toEqual({ $d: `${prefix}_${ref}` }); - expect(applied.xml).toMatch(new RegExp(`id="${prefix}_${ref}"[^>]*data(Object|Store)Ref="${prefix}_${backing}"`)); - expect(applied.result.warnings.added.map((warning) => warning.code)).not.toContain('W_ID_SUFFIXED'); - } - // a name that ends on a filler word: the cut of ` Object` drops both, so the word goes after the body (before: - // ..._zahlungseingangsbestaetigung_variante without its word, and the next reference named without the filler word - // took ..._variante_2 with W_ID_SUFFIXED) - for (const [first, second, ref, backing] of [ - ['Zahlungseingangsbestätigung Variante A', 'Zahlungseingangsbestätigung Variante', 'zahlungseingangsbestaetigung_variante', 'zahlungseingangsbestaetigung_object'], - ['Lieferung vollständig eingegangen und', 'Lieferung vollständig eingegangen', 'lieferung_vollstaendig_eingegangen', 'lieferung_vollstaendig_object'], - ]) { - const applied = await applyToXml(xml, [{ op: 'add', kind: 'dataObjectReference', name: first, in: 'Process_1', as: '$a' }, { op: 'add', kind: 'dataObjectReference', name: second, in: 'Process_1', as: '$b' }], { layout: false }); - expect(applied.result.aliases?.['$b']).toBe(`${prefix}_${ref}`); - expect(applied.xml).toMatch(new RegExp(`id="${applied.result.aliases?.['$a']}"[^>]*dataObjectRef="${prefix}_${backing}"`)); - // the two backing objects are a real collision of one id - expect(applied.xml).toMatch(new RegExp(`id="${prefix}_${ref}"[^>]*dataObjectRef="${prefix}_${backing}_2"`)); - expect(applied.result.warnings.added.filter((warning) => warning.code === 'W_ID_SUFFIXED').map((warning) => warning.message)).toEqual([`Id ${prefix}_${backing} is already taken; using ${prefix}_${backing}_2`]); - } - // bare ids: the same (before: lieferantenbestellungenVollstaendig_2) - const bare = ` - - -`; - const bareApplied = await applyToXml(bare, [{ op: 'add', kind: 'dataObjectReference', name: 'Lieferantenbestellungen vollständig abgeglichen', in: 'Process_1', as: '$d' }], { layout: false }); - expect(bareApplied.result.aliases).toEqual({ $d: 'lieferantenbestellungenVollstaendig' }); - expect(bareApplied.xml).toContain('dataObjectRef="lieferantenbestellungenVollstaendigObject"'); - expect(bareApplied.result.warnings.added.map((warning) => warning.code)).not.toContain('W_ID_SUFFIXED'); }); it('#31: the join of a split stays within 64 characters, a suffixed one too', async () => { - const join = IdStyle.DEFAULT.joinId('Gateway_ConsignationsSynchronisierenUndLieferscheinePruefenAbc'); - expect(join).toBe('Gateway_ConsignationsSynchronisierenUndLieferscheinePruefen_join'); - expect(join).toHaveLength(MAX_ID); - // a gateway id of 59 characters: its join has 64, a taken one keeps _join before the suffix - const gateway = 'Gateway_ConsignationsSynchronisierenUndLieferscheinePruefen'; + const join = IdStyle.of().joinId('gateway_consignationsSynchronisierenUndLieferscheinePruefenAbc'); + expect(join).toBe('gateway_consignationsSynchronisierenUndLieferscheinePruefenJoin'); + expect(join.length).toBeLessThanOrEqual(MAX_ID); + // a gateway id of 59 characters: its join has 63, a taken one keeps Join before the suffix + const gateway = 'gateway_consignationsSynchronisierenUndLieferscheinePruefen'; const applied = await applyToXml((await newXml({ processName: 'Join' })).xml, [ - { op: 'add', kind: 'startEvent', name: 'Go', in: 'Process_Join', as: '$s' }, + { op: 'add', kind: 'startEvent', name: 'Go', in: 'join', as: '$s' }, { op: 'add', kind: 'task', id: join, name: 'Taken', after: '$s', as: '$t' }, { op: 'split', after: '$t', id: gateway, branches: [{ nodes: [{ kind: 'task', name: 'A' }] }, { nodes: [{ kind: 'task', name: 'B' }] }], joinAs: '$j' }, ]); - expect(applied.result.aliases!['$j']).toBe('Gateway_ConsignationsSynchronisierenUndLieferscheine_join_2'); + expect(applied.result.aliases!['$j']).toBe('gateway_consignationsSynchronisierenUndLieferscheinePruefenJoin2'); expect(ids(applied.xml).filter((id) => id.length > MAX_ID)).toEqual([]); }); @@ -1133,16 +685,16 @@ describe('id quality: context once, length cap, transliteration', () => { expect(transliterate('ØRE')).toBe('OERE'); expect(transliterate('Œuvre Łódź Þing')).toBe('Oeuvre Lodz Thing'); const base = await newXml({ processName: 'Nordic' }); - const applied = await applyToXml(base.xml, [{ op: 'add', kind: 'task', name: 'Ünïcödé Çàfé Øre', in: 'Process_Nordic', as: '$t' }]); - expect(applied.result.aliases?.['$t']).toBe('Activity_UenicoedeCafeOere'); + const applied = await applyToXml(base.xml, [{ op: 'add', kind: 'task', name: 'Ünïcödé Çàfé Øre', in: 'nordic', as: '$t' }]); + expect(applied.result.aliases?.['$t']).toBe('task_uenicoedeCafeOere'); }); }); describe('bpmn new: a speaking definitions id', () => { - it('Definitions_ with --name, Definitions_ with a speaking --id, the tool defaults without either', async () => { + it('definitions_ with --name, definitions_ with a speaking --id, the tool defaults without either', async () => { const defsId = (xml: string): string => /]* id="([^"]+)"/.exec(xml)![1]!; - expect(defsId((await newXml({ processName: 'Order to cash', target: 'camunda8' })).xml)).toBe('Definitions_OrderToCash'); - expect(defsId((await newXml({ processId: 'Process_Billing' })).xml)).toBe('Definitions_Billing'); + expect(defsId((await newXml({ processName: 'Order to cash', target: 'camunda8' })).xml)).toBe('definitions_orderToCash'); + expect(defsId((await newXml({ processId: 'Process_Billing' })).xml)).toBe('definitions_billing'); expect(defsId((await newXml({})).xml)).toBe('Definitions_1'); }); }); @@ -1151,13 +703,13 @@ describe('the warnings delta', () => { it("counts the platform profile's findings the file already had", async () => { const base = await newXml({ processName: 'Pf', target: 'camunda8' }); const built = await applyToXml(base.xml, [ - { op: 'add', kind: 'startEvent', name: 'Go', in: 'Process_Pf', as: '$s' }, + { op: 'add', kind: 'startEvent', name: 'Go', in: 'pf', as: '$s' }, { op: 'add', kind: 'serviceTask', name: 'Charge', after: '$s', as: '$c' }, { op: 'add', kind: 'serviceTask', name: 'Ship', after: '$c', as: '$h' }, { op: 'add', kind: 'endEvent', name: 'Done', after: '$h' }, ]); expect((await validateXml(built.xml)).platform?.counts.deploy).toBe(2); - const applied = await applyToXml(built.xml, [{ op: 'set', id: 'Event_Done', values: { name: 'Finished' } }]); + const applied = await applyToXml(built.xml, [{ op: 'set', id: 'endEvent_done', values: { name: 'Finished' } }]); expect(applied.result.warnings.added).toEqual([]); expect(applied.result.warnings.preexistingCount).toBe(2); expect(renderSummary(mutationSummary(applied.result))).toContain('warnings: 0 added, 0 resolved, 2 already in the file'); @@ -1165,11 +717,11 @@ describe('the warnings delta', () => { it('counts what `bpmn validate` lists: a missing start once, as the platform finding', async () => { const base = await newXml({ processName: 'Empty', target: 'camunda8' }); - const xml = (await applyToXml(base.xml, [{ op: 'add', kind: 'serviceTask', name: 'Charge', in: 'Process_Empty' }])).xml; + const xml = (await applyToXml(base.xml, [{ op: 'add', kind: 'serviceTask', name: 'Charge', in: 'empty' }])).xml; const listed = (await validateXml(xml)).warnings.map((warning) => warning.code); expect(listed).toEqual(expect.arrayContaining(['W_C8_DEPLOY_START_EVENT', 'W_C8_DEPLOY_IMPLEMENTATION'])); expect(listed).not.toContain('W_NO_START'); - const applied = await applyToXml(xml, [{ op: 'set', id: 'Process_Empty', values: { name: 'Still empty' } }]); + const applied = await applyToXml(xml, [{ op: 'set', id: 'empty', values: { name: 'Still empty' } }]); expect(applied.result.warnings.added).toEqual([]); expect(applied.result.warnings.preexistingCount).toBe(listed.length); }); @@ -1179,7 +731,7 @@ describe('the warnings delta', () => { const base = await newXml({ processName: 'Dn' }); return ( await applyToXml(base.xml, [ - { op: 'add', kind: 'startEvent', name: 'Go', in: 'Process_Dn', as: '$s' }, + { op: 'add', kind: 'startEvent', name: 'Go', in: 'dn', as: '$s' }, { op: 'add', kind: 'task', name: 'Check', id: 'T1', after: '$s' }, { op: 'add', kind: 'task', name: 'Check', id: 'T2', after: 'T1' }, { op: 'add', kind: 'task', name: 'Check', id: 'T3', after: 'T2' }, @@ -1199,7 +751,7 @@ describe('the warnings delta', () => { const first = await applyToXml(xml, [{ op: 'remove', ids: ['T1'] }]); expect(first.result.warnings.added.map((warning) => warning.code)).toEqual([]); expect(first.result.warnings.resolved.map((warning) => warning.code)).toEqual([]); - const more = await applyToXml(xml, [{ op: 'add', kind: 'task', name: 'Check', id: 'T4', after: 'Event_Go' }]); + const more = await applyToXml(xml, [{ op: 'add', kind: 'task', name: 'Check', id: 'T4', after: 'startEvent_go' }]); expect(more.result.warnings.added.map((warning) => warning.code)).toContain('W_DUPLICATE_NAME'); expect(more.result.warnings.resolved.map((warning) => warning.code)).toEqual([]); }); @@ -1207,46 +759,46 @@ describe('the warnings delta', () => { it('a bridge that takes the id of the flow it replaces lists every id once: the old flow removed, the taken id changed', async () => { const xml = await threeChecks(); const applied = await applyToXml(xml, [{ op: 'remove', ids: ['T3'] }]); - expect(flowIds(applied.xml)).toEqual(expect.arrayContaining(['Flow_CheckToDone'])); - expect(flowIds(applied.xml)).not.toContain('Flow_CheckToCheck_2'); + expect(flowIds(applied.xml)).toEqual(expect.arrayContaining(['flow_checkToDone'])); + expect(flowIds(applied.xml)).not.toContain('flow_checkToCheck2'); const summary = mutationSummary(applied.result); - expect(summary.removed).toEqual(['Flow_CheckToCheck_2', 'T3']); - expect(summary.changed).toEqual(['Flow_CheckToDone']); + expect(summary.removed).toEqual(['flow_checkToCheck2', 'T3']); + expect(summary.changed).toEqual(['flow_checkToDone']); expect(summary.renamed).toBeUndefined(); const text = renderMutation(applied.result); - expect(text).toContain('changed sequenceFlow Flow_CheckToDone - T2 -> Event_Done (bridged T3)\n'); - expect(text).toContain('removed sequenceFlow Flow_CheckToCheck_2 - T2 -> T3'); + expect(text).toContain('changed sequenceFlow flow_checkToDone - T2 -> endEvent_done (bridged T3)\n'); + expect(text).toContain('removed sequenceFlow flow_checkToCheck2 - T2 -> T3'); // the same over two ops of a batch: the flow from T1 that T2's bridge kept takes the id of T3's outgoing flow const two = mutationSummary((await applyToXml(xml, [{ op: 'remove', ids: ['T2'] }, { op: 'remove', ids: ['T3'] }])).result); - expect([...two.removed].sort()).toEqual(['Flow_CheckToCheck', 'Flow_CheckToCheck_2', 'T2', 'T3']); - expect(two.changed).toEqual(['Flow_CheckToDone']); + expect([...two.removed].sort()).toEqual(['T2', 'T3', 'flow_checkToCheck', 'flow_checkToCheck2']); + expect(two.changed).toEqual(['flow_checkToDone']); expect(two.renamed).toBeUndefined(); }); it('a batch reports the warnings of its final state: lanes added before the participant that wraps the process', async () => { const base = await newXml({ processName: 'Demo' }); - const started = await applyToXml(base.xml, [{ op: 'add', kind: 'startEvent', name: 'Start', in: 'Process_Demo' }]); + const started = await applyToXml(base.xml, [{ op: 'add', kind: 'startEvent', name: 'Start', in: 'demo' }]); const applied = await applyToXml(started.xml, [ - { op: 'add', kind: 'lane', name: 'Sales', in: 'Process_Demo', members: ['Event_Start'] }, - { op: 'add', kind: 'participant', name: 'Seller', process: 'Process_Demo' }, + { op: 'add', kind: 'lane', name: 'Sales', in: 'demo', members: ['startEvent_start'] }, + { op: 'add', kind: 'participant', name: 'Seller', process: 'demo' }, ]); expect(applied.result.warnings.added.map((warning) => warning.code)).not.toContain('W_LANES_WITHOUT_POOL'); // without the participant it still holds - const lanesOnly = await applyToXml(started.xml, [{ op: 'add', kind: 'lane', name: 'Sales', in: 'Process_Demo', members: ['Event_Start'] }]); + const lanesOnly = await applyToXml(started.xml, [{ op: 'add', kind: 'lane', name: 'Sales', in: 'demo', members: ['startEvent_start'] }]); expect(lanesOnly.result.warnings.added.map((warning) => warning.code)).toContain('W_LANES_WITHOUT_POOL'); }); it('a batch drops an implicit join a later op of it resolved', async () => { const base = await newXml({ processName: 'Join' }); const built = await applyToXml(base.xml, [ - { op: 'add', kind: 'startEvent', name: 'Start', in: 'Process_Join', as: '$s' }, + { op: 'add', kind: 'startEvent', name: 'Start', in: 'join', as: '$s' }, { op: 'split', after: '$s', kind: 'parallelGateway', join: false, branches: [{ nodes: [{ kind: 'task', name: 'Left' }] }, { nodes: [{ kind: 'task', name: 'Right' }] }] }, - { op: 'add', kind: 'endEvent', name: 'Done', after: 'Activity_Left' }, + { op: 'add', kind: 'endEvent', name: 'Done', after: 'task_left' }, ]); const join = [ - { op: 'connect', source: 'Activity_Right', target: 'Event_Done', as: '$right' }, - { op: 'add', kind: 'parallelGateway', id: 'Gateway_Sync', flow: 'Flow_LeftToDone' }, - { op: 'set', id: '$right', values: { target: 'Gateway_Sync' } }, + { op: 'connect', source: 'task_right', target: 'endEvent_done', as: '$right' }, + { op: 'add', kind: 'parallelGateway', id: 'gateway_sync', flow: 'flow_leftToDone' }, + { op: 'set', id: '$right', values: { target: 'gateway_sync' } }, ]; expect((await applyToXml(built.xml, join.slice(0, 1))).result.warnings.added.map((warning) => warning.code)).toContain('W_IMPLICIT_JOIN'); const applied = await applyToXml(built.xml, join); @@ -1258,7 +810,7 @@ describe('the warnings delta', () => { const shown = base.result.warnings.added.map((warning) => warning.code); expect(shown).toContain('W_C8_DEPLOY_START_EVENT'); expect(shown).not.toContain('W_NO_START'); - const applied = await applyToXml(base.xml, [{ op: 'add', kind: 'startEvent', name: 'Go', in: 'Process_Order' }]); + const applied = await applyToXml(base.xml, [{ op: 'add', kind: 'startEvent', name: 'Go', in: 'order' }]); expect(applied.result.warnings.resolved.map((warning) => warning.code)).toEqual(['W_C8_DEPLOY_START_EVENT']); expect(shown).toEqual(expect.arrayContaining(applied.result.warnings.resolved.map((warning) => warning.code))); }); diff --git a/test/domain/document/ids/id-style.test.ts b/test/domain/document/ids/id-style.test.ts index 2eb37182..93febd84 100644 --- a/test/domain/document/ids/id-style.test.ts +++ b/test/domain/document/ids/id-style.test.ts @@ -1,24 +1,17 @@ /** - * Edits follow the file: generated ids take the id style of the file they - * are written into (src/domain/document/ids/IdStyle.ts) and say what they name (never a hash or - * a running number: names, kind + context, the ends of a flow), names are - * transliterated the German way. All fixtures are synthetic. + * New ids follow the conventions of element-id-naming, its defaults where nothing is declared + * (src/domain/document/ids/IdStyle.ts), and say what they name (never a hash or a running number: + * names, kind + context, the ends of a flow), names are transliterated the German way. All + * fixtures are synthetic. */ -import { readFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; -import { applyToXml } from '../../../../src/api.js'; import { Doc } from '../../../../src/domain/document/Doc.js'; -import { IdStyle } from '../../../../src/domain/document/ids/IdStyle.js'; import { runOps } from '../../../../src/index.js'; import type { EditCommand } from '../../../../src/domain/commands/types.js'; import { mutateDoc } from '../../../../src/index.js'; import { guideText, kindsJson } from '../../../../src/index.js'; import { definitionsXml, flowBetween } from '../../../helpers/helpers.js'; -const HERE = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); - /** A Camunda Modeler hash or a number: what a generated id must never end in. */ const NOT_SPEAKING = /_([01][0-9a-z]{6}|\d+)$/; @@ -50,7 +43,7 @@ const MODELER = chain( ['Flow_0a1b2c3', 'Flow_1q2w3e4', 'Flow_0p9o8i7', 'Flow_1l2k3j4'], ); -/** Type-named lowerCamel prefixes with camelCase bodies and `flow_To` flows. */ +/** The defaults: type-named lowerCamel prefixes with camelCase bodies and `flow_To` flows. */ const CAMEL = chain( [ ['startEvent', 'startEvent_orderReceived', 'Order received'], @@ -63,18 +56,6 @@ const CAMEL = chain( ['flow_orderReceivedToCheckStock', 'flow_checkStockToApproveOrder', 'flow_approveOrderToIsApproved', 'flow_isApprovedToShipOrder', 'flow_shipOrderToOrderShipped'], ); -/** design-iq style: `Task_snake_case`, `Flow__` with the full ids. */ -const SNAKE = chain( - [ - ['startEvent', 'Event_order_received', 'Order received'], - ['userTask', 'Task_check_risk', 'Check risk'], - ['exclusiveGateway', 'Gateway_risk_ok', 'Risk ok?'], - ['serviceTask', 'Task_approve_order', 'Approve order'], - ['endEvent', 'Event_order_done', 'Order done'], - ], - ['Flow_Event_order_received_Task_check_risk', 'Flow_Task_check_risk_Gateway_risk_ok', 'Flow_Gateway_risk_ok_Task_approve_order', 'Flow_Task_approve_order_Event_order_done'], -); - /** Numbered ids with custom prefixes (`Task_1`, `Start_1`, `End_1`, `SF_1`). */ const NUMBERED = chain( [ @@ -97,148 +78,59 @@ const OLD_MODELER = chain( ['SequenceFlow_0aaa111', 'SequenceFlow_1bbb222', 'SequenceFlow_0ccc333'], ); -describe('id style inference (edits follow the file)', () => { - it('learns nothing from a file without conventions: the bpmn-cli default', async () => { - const style = IdStyle.infer((await Doc.fromXml(definitionsXml(''))).definitions).info(); - expect(style).toEqual({ body: 'pascal', sequenceFlow: { form: 'named', prefix: 'Flow' }, messageFlow: { form: 'named', prefix: 'Flow' } }); - }); - - it('a Camunda Modeler file gets speaking ids with the modeler prefixes; flows name their ends', async () => { +describe('new ids follow the defaults of element-id-naming', () => { + it('a Camunda Modeler file gets speaking ids with the default prefixes; flows name their ends', async () => { const ops: EditCommand[] = [{ op: 'add', kind: 'userTask', name: 'Pack order', after: 'Activity_0k3x9qa' }]; const spliceEdit = await created(MODELER, ops); - // hashes show no case: pascal bodies, the file's prefixes and flow prefix - expect(IdStyle.infer((await Doc.fromXml(MODELER)).definitions).info()).toEqual({ body: 'pascal', sequenceFlow: { form: 'named', prefix: 'Flow' }, messageFlow: { form: 'named', prefix: 'Flow' } }); // `--after` splices: the anchor's (hashed) flow keeps its id and now ends at the new node, a new flow runs on to the // old target, named after the ends (the gateway by its name: its id says nothing) - expect(spliceEdit.ids).toEqual(['Activity_PackOrder', 'Flow_PackOrderToOrderOk']); - expect(flowBetween(spliceEdit.doc, 'Activity_0k3x9qa', 'Activity_PackOrder')).toBe('Flow_1q2w3e4'); + expect(spliceEdit.ids).toEqual(['userTask_packOrder', 'flow_packOrderToOrderOk']); + expect(flowBetween(spliceEdit.doc, 'Activity_0k3x9qa', 'userTask_packOrder')).toBe('Flow_1q2w3e4'); // the same edit on the same file gives the same ids expect((await created(MODELER, ops)).ids).toEqual(spliceEdit.ids); // unnamed elements: a word for the kind and the context (Timer On , After ) const unnamedEdit = await created(MODELER, [ { op: 'add', kind: 'boundaryEvent:timer', on: 'Activity_0k3x9qa', timer: 'PT1H' }, - { op: 'add', kind: 'endEvent', after: 'Event_TimerOnCheckOrder' }, + { op: 'add', kind: 'endEvent', after: 'event_timerOnCheckOrder' }, { op: 'add', kind: 'parallelGateway', after: 'Activity_1m2n3b4' }, ]); // a flow names an unnamed end by its own id's speaking part (its kind and place), never by a bare kind word - expect(unnamedEdit.ids).toEqual(['Event_TimerOnCheckOrder', 'Event_EndAfterTimerOnCheckOrder', 'Flow_TimerOnCheckOrderToEndAfterTimerOnCheckOrder', 'Gateway_AfterShipOrder', 'Flow_AfterShipOrderToOrderShipped']); + expect(unnamedEdit.ids).toEqual(['event_timerOnCheckOrder', 'endEvent_afterTimerOnCheckOrder', 'flow_timerOnCheckOrderToAfterTimerOnCheckOrder', 'gateway_afterShipOrder', 'flow_afterShipOrderToOrderShipped']); for (const id of [...spliceEdit.ids, ...unnamedEdit.ids]) expect(id).not.toMatch(NOT_SPEAKING); }); - it('type-named camelCase files: serviceTask_checkStock, flow_checkStockToApproveOrder', async () => { + it('type-named camelCase ids: serviceTask_checkStock, flow_checkStockToApproveOrder', async () => { const { ids, doc } = await created(CAMEL, [ { op: 'add', kind: 'userTask', name: 'Pack goods', after: 'serviceTask_checkStock' }, { op: 'add', kind: 'sendTask', name: 'Notify customer', after: 'serviceTask_shipOrder' }, { op: 'add', kind: 'parallelGateway', in: 'Process_1' }, ]); expect(ids.slice(0, 2)).toEqual(['userTask_packGoods', 'flow_packGoodsToApproveOrder']); - // sendTask is new to the file: its type names the prefix like the other kinds' types do expect(ids.slice(2, 4)).toEqual(['sendTask_notifyCustomer', 'flow_notifyCustomerToOrderShipped']); - // an unnamed gateway: the file's gateway prefix, a word for the kind (no placement context: --in the process) + // an unnamed gateway: the gateway prefix, a word for the kind (no placement context: --in the process) expect(ids[4]).toBe('gateway_parallel'); - // the spliced flow named its old ends in the file's form: it is renamed after its new ones (and reported so) + // the spliced flow named its old ends: it is renamed after its new ones (and reported so) expect(flowBetween(doc, 'serviceTask_checkStock', 'userTask_packGoods')).toBe('flow_checkStockToPackGoods'); }); - it('snake_case files with flows named after their ends: Task_pack_goods, Flow__', async () => { - const { ids } = await created(SNAKE, [ - { op: 'add', kind: 'serviceTask', name: 'Pack goods', after: 'Task_check_risk' }, - { op: 'add', kind: 'endEvent', name: 'Order rejected', after: 'Gateway_risk_ok' }, - ]); - expect(ids).toEqual(['Task_pack_goods', 'Flow_Task_pack_goods_Gateway_risk_ok', 'Event_order_rejected', 'Flow_Gateway_risk_ok_Event_order_rejected']); - }); - - it('#31: a flow between two long ids keeps both prefixes and cuts whole words after them, and still follows its ends', async () => { - const { doc, ids } = await created(SNAKE, [ - { op: 'add', kind: 'userTask', name: 'Lieferantenbestellungen prüfen', after: 'Task_check_risk' }, - { op: 'add', kind: 'userTask', name: 'Zahlungseingänge abgleichen und Rechnung archivieren', after: 'Task_lieferantenbestellungen_pruefen' }, - ]); - // the name's 40 characters end before a filler word (before: Task_zahlungseingaenge_abgleichen_und) - expect(ids).toContain('Task_zahlungseingaenge_abgleichen'); - // 0.6: Flow_Task_pruefen_Task_zahlungseingaenge_abgleichen_und - const cut = 'Flow_Task_lieferantenbestellungen_pruefen_Task_zahlungseingaenge'; - expect(flowBetween(doc, 'Task_lieferantenbestellungen_pruefen', 'Task_zahlungseingaenge_abgleichen')).toBe(cut); - const cs = runOps(doc, [{ op: 'add', kind: 'userTask', name: 'Beleg scannen', after: 'Task_lieferantenbestellungen_pruefen' }]); - expect(cs.renames).toEqual([expect.objectContaining({ from: cut, to: 'Flow_Task_lieferantenbestellungen_pruefen_Task_beleg_scannen' })]); - }); - - it('numbered files keep their prefixes, but the bodies speak (no running numbers)', async () => { - const { ids } = await created(NUMBERED, [ - { op: 'add', kind: 'task', name: 'Third', after: 'Task_2' }, - { op: 'add', kind: 'endEvent', name: 'Abort', in: 'Process_1' }, - ]); - expect(ids).toEqual(['Task_Third', 'SF_ThirdToFinish', 'End_Abort']); - }); - - it('old modeler files: type-named prefixes and SequenceFlow_ flows, speaking bodies', async () => { - const { ids } = await created(OLD_MODELER, [ - { op: 'add', kind: 'task', name: 'Archive', after: 'Task_0cde345' }, - { op: 'add', kind: 'exclusiveGateway', name: 'Ok?', in: 'Process_1' }, - ]); - expect(ids).toEqual(['Task_Archive', 'SequenceFlow_ArchiveToDone', 'ExclusiveGateway_Ok']); - }); - - it('custom prefixes per kind (Start_, End_), the family prefix (Task_) for a new task kind', async () => { - const xml = chain( - [ - ['startEvent', 'Start_Received', 'Received'], - ['userTask', 'Task_Check', 'Check'], - ['serviceTask', 'Task_Book', 'Book'], - ['endEvent', 'End_Done', 'Done'], - ['endEvent', 'End_Failed', 'Failed'], - ], - ['Flow_a', 'Flow_b', 'Flow_c', 'Flow_d'], - ); - const { ids } = await created(xml, [ - { op: 'add', kind: 'sendTask', name: 'Notify', in: 'Process_1' }, - { op: 'add', kind: 'endEvent', name: 'Cancelled', in: 'Process_1' }, - { op: 'add', kind: 'boundaryEvent:timer', name: 'Late', on: 'Task_Check' }, + it('#31: a flow between two long names cuts whole words of each end, and still follows its ends', async () => { + const { doc, ids } = await created(CAMEL, [ + { op: 'add', kind: 'userTask', name: 'Lieferantenbestellungen prüfen', after: 'serviceTask_checkStock' }, + { op: 'add', kind: 'userTask', name: 'Zahlungseingänge abgleichen und Rechnung archivieren', after: 'userTask_lieferantenbestellungenPruefen' }, ]); - expect(ids).toEqual(['Task_Notify', 'End_Cancelled', 'Event_Late']); - }); - - it('Task_ on a plain task is the family prefix, not evidence for type-named prefixes', async () => { - // type-named file with a plain Task_ task: a new user task is userTask_ - const typed = chain( - [ - ['startEvent', 'startEvent_go', 'Go'], - ['serviceTask', 'serviceTask_load', 'Load'], - ['task', 'Task_check', 'Check'], - ['serviceTask', 'serviceTask_store', 'Store'], - ['endEvent', 'endEvent_done', 'Done'], - ], - ['f1', 'f2', 'f3', 'f4'], - ); - expect((await created(typed, [{ op: 'add', kind: 'userTask', name: 'Approve', in: 'Process_1' }])).ids).toEqual(['userTask_approve']); - // Task_ for every task, custom event prefixes: a new user task is Task_ - const plain = chain( - [ - ['startEvent', 'Start_go', 'Go'], - ['task', 'Task_load', 'Load'], - ['task', 'Task_check', 'Check'], - ['endEvent', 'End_done', 'Done'], - ], - ['f1', 'f2', 'f3'], - ); - expect((await created(plain, [{ op: 'add', kind: 'userTask', name: 'Approve', in: 'Process_1' }])).ids).toEqual(['Task_approve']); + // the name gives at most 40 characters of whole words + expect(ids).toContain('userTask_zahlungseingaengeAbgleichenUndRechnung'); + const cut = 'flow_lieferantenbestellungenPruefenToZahlungseingaengeAbgleichen'; + expect(flowBetween(doc, 'userTask_lieferantenbestellungenPruefen', 'userTask_zahlungseingaengeAbgleichenUndRechnung')).toBe(cut); + const cs = runOps(doc, [{ op: 'add', kind: 'userTask', name: 'Beleg scannen', after: 'userTask_lieferantenbestellungenPruefen' }]); + expect(cs.renames).toEqual([expect.objectContaining({ from: cut, to: 'flow_lieferantenbestellungenPruefenToBelegScannen' })]); }); - it('the body style comes from the flow nodes when they show one (pools and lanes often keep other ids)', async () => { - const xml = definitionsXml( - [ - '', - '', - '', - ].join('\n'), - ); - expect((await created(xml, [{ op: 'add', kind: 'task', name: 'Check', in: 'Process_1' }])).ids).toEqual(['Task_Check']); - }); - - it('warns W_ID_SUFFIXED and names the --if-absent id in the file style', async () => { + it('warns W_ID_SUFFIXED and names the --if-absent id', async () => { const doc = await Doc.fromXml(CAMEL); const cs = runOps(doc, [{ op: 'add', kind: 'serviceTask', name: 'Check stock', in: 'Process_1' }]); - expect(cs.created[0]!.id).toBe('serviceTask_checkStock_2'); - expect(cs.warnings.map((warning) => warning.message)).toEqual(['Id serviceTask_checkStock is already taken; using serviceTask_checkStock_2']); + expect(cs.created[0]!.id).toBe('serviceTask_checkStock2'); + expect(cs.warnings.map((warning) => warning.message)).toEqual(['Id serviceTask_checkStock is already taken; using serviceTask_checkStock2']); let hint = ''; try { runOps(doc, [{ op: 'add', kind: 'userTask', name: 'Pack goods', in: 'Process_1', ifAbsent: true }]); @@ -248,196 +140,25 @@ describe('id style inference (edits follow the file)', () => { expect(hint).toContain('--id userTask_packGoods'); }); - it('a split joins at a gateway named in the file style (camel: Join; else _join)', async () => { + it('a split joins at Join', async () => { const split: EditCommand = { op: 'split', after: 'userTask_approveOrder', kind: 'parallel', name: 'Fan out', branches: [{ nodes: [{ kind: 'task', name: 'Left' }] }, { nodes: [{ kind: 'task', name: 'Right' }] }] }; expect((await created(CAMEL, [split])).ids).toContain('gateway_fanOutJoin'); const modeler = await created(MODELER, [{ ...split, after: 'Activity_0k3x9qa' } as EditCommand]); - expect(modeler.ids.filter((id) => id.startsWith('Gateway_'))).toEqual(['Gateway_FanOut', 'Gateway_FanOut_join']); + expect(modeler.ids.filter((id) => id.startsWith('gateway_'))).toEqual(['gateway_fanOut', 'gateway_fanOutJoin']); // an unnamed split gateway: After const unnamed = await created(MODELER, [{ ...split, name: undefined, after: 'Activity_0k3x9qa' } as EditCommand]); - expect(unnamed.ids.filter((id) => id.startsWith('Gateway_'))).toEqual(['Gateway_AfterCheckOrder', 'Gateway_AfterCheckOrder_join']); - // the gateways' flows name them by their ids' speaking parts: the split AfterCheckOrder, its join CheckOrderJoin - expect(unnamed.ids).toEqual(expect.arrayContaining(['Flow_AfterCheckOrderToLeft', 'Flow_LeftToCheckOrderJoin', 'Flow_CheckOrderJoinToOrderOk'])); - }); -}); - -describe('id style inference: ids without prefix, numbers without separator, scoped flow names', () => { - /** Hand-written ids: camelCase without prefix, flows numbered without separator. */ - const BARE = chain( - [ - ['startEvent', 'orderReceived', 'Order received'], - ['task', 'checkOrder', 'Check order'], - ['task', 'packGoods', 'Pack goods'], - ['task', 'shipOrder', 'Ship order'], - ['endEvent', 'orderShipped', 'Order shipped'], - ], - ['flow1', 'flow2', 'flow3', 'flow4'], - ); - - it('a file whose ids have no prefix gets bare camelCase ids; flows keep the glued prefix (flow12 -> flowReviewOrderToPackGoods)', async () => { - const { ids } = await created(BARE, [{ op: 'add', kind: 'task', name: 'Review order', after: 'checkOrder' }]); - expect(ids).toEqual(['reviewOrder', 'flowReviewOrderToPackGoods']); - // another kind of the family (no own evidence) follows the family; a second one with the same name gets a suffix - const more = await created(BARE, [ - { op: 'add', kind: 'userTask', name: 'Review order', after: 'checkOrder' }, - { op: 'add', kind: 'userTask', name: 'Review order', after: 'packGoods' }, - ]); - expect(more.ids).toEqual(['reviewOrder', 'flowReviewOrderToPackGoods', 'reviewOrder_2', 'flowReviewOrder2ToShipOrder']); - expect(more.warnings).toEqual(['W_ID_SUFFIXED']); - }); - - it("#31: bpmn-cli's own suffixed flows do not vote for a prefix: the file keeps flowAToB, a splice follows them, every id within 64", async () => { - const { doc } = await created(BARE, [ - { op: 'add', kind: 'task', name: 'Vertragsunterlagenarchivierung an', after: 'shipOrder', as: '$a' }, - { op: 'add', kind: 'task', name: 'Kundenstammdatenaktualisierung café', after: '$a', as: '$b' }, - ...[1, 2, 3, 4].map((): EditCommand => ({ op: 'connect', source: '$a', target: '$b' })), - ]); - const suffixed = 'flowVertragsunterlagenarchivieToKundenstammdatenaktualisierung_5'; - expect(doc.ids.has(suffixed)).toBe(true); - // flowVertragsunterlagenarchivieToKundenstammdatenaktualisierung_2 is no prefix and a number (0.6 learned it: ..._ToKundenstammdatena, 82 characters) - const again = await Doc.fromXml(await doc.toXml()); - expect(again.ownIdStyle.info().sequenceFlow).toEqual({ form: 'named', prefix: 'flow', sep: '' }); - const cs = runOps(again, [{ op: 'add', kind: 'task', name: 'Zwischenschritt', flow: suffixed }]); - expect(cs.renames).toEqual([expect.objectContaining({ from: suffixed, to: 'flowVertragsunterlagenarchivierungAnToZwischenschritt' })]); - expect([...again.ids.values()].filter((id) => id.length > 64)).toEqual([]); - }); - - it("#31: bpmn-cli's own suffixed flows still count for a flow12 file's form, as in 0.6: the file keeps flowAToB as long", async () => { - const tasks = ['Check', 'Ship', 'Bill', 'Archive', 'Pay', 'Log', 'Send'].map((taskName): [string, string, string] => ['task', `Activity_${taskName}`, taskName]); - const glued = chain(tasks, ['flow1', 'flow2', 'flow3', 'flowArchiveToPay', 'flowPayToLog', 'flowLogToSend']); - const { doc } = await created(glued, [ - { op: 'connect', source: 'Activity_Pay', target: 'Activity_Log' }, - { op: 'add', kind: 'task', name: 'Done', after: 'Activity_Send' }, - ]); - expect(doc.ids.has('flowPayToLog_2') && doc.ids.has('flowSendToDone')).toBe(true); - // three flow12 ids, four glued flows and one with a suffix (before: the suffixed one did not count, the file read as - // Flow_, and the splice kept flowLogToSend on the flow to Audit) - const again = await Doc.fromXml(await doc.toXml()); - expect(again.ownIdStyle.info().sequenceFlow).toEqual({ form: 'named', prefix: 'flow', sep: '' }); - const cs = runOps(again, [{ op: 'add', kind: 'task', name: 'Audit', flow: 'flowLogToSend' }]); - expect(cs.renames).toEqual([expect.objectContaining({ from: 'flowLogToSend', to: 'flowLogToAudit' })]); - expect(cs.created.map((created) => created.id)).toEqual(['Activity_Audit', 'flowAuditToSend']); - // and after three more commands: the glued flows without a suffix vote for flow12's prefix too (before: three flow12 - // ids and the suffixed flow against eight of them, the file read as Flow_ and the splice kept flowLogToAudit) - let xml = await again.toXml(); - for (const [name, after] of [['File', 'Activity_Done'], ['Close', 'Activity_File'], ['Report', 'Activity_Close']]) { - const next = await Doc.fromXml(xml); - runOps(next, [{ op: 'add', kind: 'task', name, after }]); - xml = await next.toXml(); - } - const later = await Doc.fromXml(xml); - expect(later.ownIdStyle.info().sequenceFlow).toEqual({ form: 'named', prefix: 'flow', sep: '' }); - const spliced = runOps(later, [{ op: 'add', kind: 'task', name: 'Weigh', flow: 'flowLogToAudit' }]); - expect(spliced.renames).toEqual([expect.objectContaining({ from: 'flowLogToAudit', to: 'flowLogToWeigh' })]); - expect(spliced.created.map((created) => created.id)).toEqual(['Activity_Weigh', 'flowWeighToAudit']); - }); - - it('an unnamed element in such a file still gets a prefix (its kind and context need one)', async () => { - const { ids } = await created(BARE, [{ op: 'add', kind: 'exclusiveGateway', after: 'packGoods' }]); - expect(ids).toEqual(['Gateway_AfterPackGoods', 'flowAfterPackGoodsToShipOrder']); - }); - - it('single lower-case words read as camelCase (not snake_case, which would read as a prefix); a PascalCase file stays PascalCase', async () => { - const lower = chain( - [ - ['startEvent', 'start', 'Start'], - ['serviceTask', 'prepare', 'Prepare'], - ['serviceTask', 'branch1', 'Branch 1'], - ['parallelGateway', 'join'], - ['endEvent', 'end', 'End'], - ], - ['f0', 'f1', 'f2', 'f3'], - ); - expect((await created(lower, [{ op: 'add', kind: 'serviceTask', name: 'Check stock', after: 'prepare' }])).ids).toEqual(['checkStock', 'fCheckStockToBranch1']); - const pascal = chain( - [ - ['startEvent', 'OrderReceived', 'Order received'], - ['task', 'CheckOrder', 'Check order'], - ['task', 'ShipOrder', 'Ship order'], - ['endEvent', 'OrderShipped', 'Order shipped'], - ], - ['Flow_1', 'Flow_2', 'Flow_3'], - ); - expect((await created(pascal, [{ op: 'add', kind: 'task', name: 'Review order', after: 'CheckOrder' }])).ids).toEqual(['ReviewOrder', 'Flow_ReviewOrderToShipOrder']); - // a kind of another family without own evidence: the file's flow nodes have no prefixes, so neither does it - expect((await created(pascal, [{ op: 'add', kind: 'exclusiveGateway', name: 'Order ok?', after: 'CheckOrder' }])).ids).toEqual(['OrderOk', 'Flow_OrderOkToShipOrder']); - }); - - it('a kind without prefix next to prefixed kinds: only that kind goes bare', async () => { - const mixed = chain( - [ - ['startEvent', 'StartEvent_1'], - ['userTask', 'first', 'First'], - ['userTask', 'second', 'Second'], - ['endEvent', 'Event_16m42dv'], - ], - ['Flow_03y1k7c', 'Flow_02qw1n8', 'Flow_01kyget'], - ); - const { ids } = await created(mixed, [{ op: 'add', kind: 'userTask', name: 'Review order', after: 'first' }]); - expect(ids).toEqual(['reviewOrder', 'Flow_ReviewOrderToSecond']); - // the events are prefixed (Event_16m42dv): a new event keeps a prefix - expect((await created(mixed, [{ op: 'add', kind: 'boundaryEvent:timer', name: 'Timeout', on: 'first', timer: 'PT1H' }])).ids).toEqual(['Event_Timeout']); - }); - - it('flows named Flow__To: the scope of the ends and the first word of each end', async () => { - const scoped = chain( - [ - ['startEvent', 'startEvent_KotOrder', 'Order placed'], - ['serviceTask', 'serviceTask_KotO_ValidatePayment', 'Validate payment'], - ['serviceTask', 'serviceTask_KotO_ReserveInventory', 'Reserve inventory'], - ['userTask', 'userTask_KotO_ConfirmShipment', 'Confirm shipment'], - ['endEvent', 'endEvent_KotOrder', 'Order done'], - ], - ['Flow_KotO_StartToValidate', 'Flow_KotO_ValidateToReserve', 'Flow_KotO_ReserveToConfirm', 'Flow_KotO_ConfirmToEnd'], - ); - const doc = await Doc.fromXml(scoped); - expect(doc.ownIdStyle.info().sequenceFlow).toEqual({ form: 'scopedTo', prefix: 'Flow', scope: 'KotO' }); - const reviewEdit = await created(scoped, [{ op: 'add', kind: 'userTask', id: 'userTask_KotO_ReviewOrder', name: 'Review order', after: 'serviceTask_KotO_ReserveInventory' }]); - expect(reviewEdit.ids).toEqual(['userTask_KotO_ReviewOrder', 'Flow_KotO_ReviewToConfirm']); - // an end event as the target, a start event as the source; a second flow of the same words gets a number - const ends = await created(scoped, [ - { op: 'add', kind: 'endEvent', id: 'endEvent_KotO_Failed', name: 'Failed', in: 'Process_1' }, - { op: 'connect', source: 'serviceTask_KotO_ValidatePayment', target: 'endEvent_KotO_Failed' }, - { op: 'connect', source: 'startEvent_KotOrder', target: 'userTask_KotO_ConfirmShipment' }, - { op: 'connect', source: 'startEvent_KotOrder', target: 'userTask_KotO_ConfirmShipment' }, - ]); - expect(ends.ids).toEqual(['endEvent_KotO_Failed', 'Flow_KotO_ValidateToEnd', 'Flow_KotO_StartToConfirm', 'Flow_KotO_StartToConfirm2']); - }); - - it('#31: long end words share the room like any flow\'s ends, and a number is no end word', async () => { - const scoped = chain( - [ - ['startEvent', 'startEvent_KotOrder', 'Order placed'], - ['serviceTask', 'serviceTask_KotO_ValidatePayment', 'Validate payment'], - ['serviceTask', 'serviceTask_KotO_ReserveInventory', 'Reserve inventory'], - ['endEvent', 'endEvent_KotOrder', 'Order done'], - ], - ['Flow_KotO_StartToValidate', 'Flow_KotO_ValidateToReserve', 'Flow_KotO_ReserveToEnd'], - ); - const { doc } = await created(scoped, [ - { op: 'add', kind: 'serviceTask', id: 'serviceTask_KotO_Warenwirtschaftssystemabgleich', name: 'Warenwirtschaftssystemabgleich', after: 'serviceTask_KotO_ValidatePayment' }, - { op: 'add', kind: 'serviceTask', id: 'serviceTask_KotO_Kundenstammdatenaktualisierung', name: 'Kundenstammdatenaktualisierung', after: 'serviceTask_KotO_Warenwirtschaftssystemabgleich' }, - { op: 'add', kind: 'serviceTask', id: 'serviceTask_KotO_4AugenPruefen', name: '4 Augen prüfen', after: 'serviceTask_KotO_ReserveInventory' }, - { op: 'connect', source: 'serviceTask_KotO_Warenwirtschaftssystemabgleich', target: 'serviceTask_KotO_Kundenstammdatenaktualisierung' }, - ]); - // 0.6: Flow_KotO_WarenwirtschaftssystemabgleichTo, the target gone; then ...To2, which the form no longer reads - expect(flowBetween(doc, 'serviceTask_KotO_Warenwirtschaftssystemabgleich', 'serviceTask_KotO_Kundenstammdatenaktualisierung')).toBe('Flow_KotO_WarenwirtschaftssystemToKundenstammdatenaktualisierung'); - expect(doc.ids.has('Flow_KotO_WarenwirtschaftssysteToKundenstammdatenaktualisierung2')).toBe(true); - // 0.6: Flow_KotO_ReserveTo4 (a second one: ...To42) - expect(flowBetween(doc, 'serviceTask_KotO_ReserveInventory', 'serviceTask_KotO_4AugenPruefen')).toBe('Flow_KotO_ReserveToAugen'); - expect(IdStyle.infer(doc.definitions).info().sequenceFlow).toEqual({ form: 'scopedTo', prefix: 'Flow', scope: 'KotO' }); + expect(unnamed.ids.filter((id) => id.startsWith('gateway_'))).toEqual(['gateway_afterCheckOrder', 'gateway_afterCheckOrderJoin']); + // the gateways' flows name them by their ids' speaking parts: the split afterCheckOrder, its join checkOrderJoin + expect(unnamed.ids).toEqual(expect.arrayContaining(['flow_afterCheckOrderToLeft', 'flow_leftToCheckOrderJoin', 'flow_checkOrderJoinToOrderOk'])); }); - it('a glued suffix after an end that ends in a digit takes the word No (stemTo, scopedTo); such flows and 0.6\'s glued ones follow their ends', async () => { - // before: flow_checkStockToSendPdf102 (a flow to Send PDF 102?), flow_checkStockToSend_22 + it('a glued suffix after an end that ends in a digit takes the word No; such flows follow their ends', async () => { + // before: flow_checkStockToSendPdf102 (a flow to Send PDF 102?) const { doc, ids } = await created(CAMEL, [ { op: 'add', kind: 'serviceTask', id: 'serviceTask_sendPdf10', name: 'Send PDF 10', in: 'Process_1' }, { op: 'connect', source: 'serviceTask_checkStock', target: 'serviceTask_sendPdf10' }, { op: 'connect', source: 'serviceTask_checkStock', target: 'serviceTask_sendPdf10' }, { op: 'connect', source: 'serviceTask_checkStock', target: 'serviceTask_sendPdf10' }, - { op: 'add', kind: 'serviceTask', id: 'serviceTask_send_2', name: 'Send', in: 'Process_1' }, - { op: 'connect', source: 'serviceTask_checkStock', target: 'serviceTask_send_2' }, - { op: 'connect', source: 'serviceTask_checkStock', target: 'serviceTask_send_2' }, { op: 'connect', source: 'serviceTask_checkStock', target: 'userTask_approveOrder' }, ]); expect(ids).toEqual([ @@ -445,210 +166,23 @@ describe('id style inference: ids without prefix, numbers without separator, sco 'flow_checkStockToSendPdf10', 'flow_checkStockToSendPdf10No2', 'flow_checkStockToSendPdf10No3', - 'serviceTask_send_2', - 'flow_checkStockToSend_2', - 'flow_checkStockToSend_2No2', // an end that ends in a letter keeps the glued 2 'flow_checkStockToApproveOrder2', ]); - // they vote for the file's form, and a splice renames them after their new ends - expect(IdStyle.infer(doc.definitions).info().sequenceFlow).toEqual({ form: 'stemTo', prefix: 'flow' }); const spliced = runOps(doc, [{ op: 'add', kind: 'serviceTask', id: 'serviceTask_pack', name: 'Pack', flow: 'flow_checkStockToSendPdf10No2' }]); expect(spliced.renames).toEqual([expect.objectContaining({ from: 'flow_checkStockToSendPdf10No2', to: 'flow_checkStockToPack' })]); - // a scoped flow whose end word ends in a digit - const scoped = chain( - [ - ['serviceTask', 'serviceTask_KotO_ValidatePayment', 'Validate payment'], - ['serviceTask', 'serviceTask_KotO_ReserveInventory', 'Reserve inventory'], - ['serviceTask', 'serviceTask_KotO_Pdf10Senden', 'PDF 10 senden'], - ], - ['Flow_KotO_ValidateToReserve', 'Flow_KotO_ReserveToPdf10'], - ); - const connectEdit = await created(scoped, [{ op: 'connect', source: 'serviceTask_KotO_ReserveInventory', target: 'serviceTask_KotO_Pdf10Senden' }]); - expect(connectEdit.ids).toEqual(['Flow_KotO_ReserveToPdf10No2']); - // 0.6 glued the number to the digit: its flows still follow their ends - const old = chain( - [ - ['startEvent', 'startEvent_orderReceived', 'Order received'], - ['serviceTask', 'serviceTask_checkStock', 'Check stock'], - ['serviceTask', 'serviceTask_sendPdf10', 'Send PDF 10'], - ], - ['flow_orderReceivedToCheckStock', 'flow_checkStockToSendPdf102'], - ); - const followed = runOps(await Doc.fromXml(old), [{ op: 'add', kind: 'serviceTask', id: 'serviceTask_pack', name: 'Pack', flow: 'flow_checkStockToSendPdf102' }]); - expect(followed.renames).toEqual([expect.objectContaining({ from: 'flow_checkStockToSendPdf102', to: 'flow_checkStockToPack' })]); - }); - - it('the scoped form is not read into other forms', async () => { - for (const xml of [CAMEL, SNAKE, MODELER]) expect((await Doc.fromXml(xml)).ownIdStyle.info().sequenceFlow.form).not.toBe('scopedTo'); }); }); -describe('a file\'s flow form stays put, and flows named after their ends follow under every prefix the file uses', () => { - /** Tasks A..F, flows `[id, source, target]` in this order. */ - const tasks = (flows: Array<[string, string, string]>): string => - definitionsXml( - [ - ...['A', 'B', 'C', 'D', 'E', 'F'].map((letter) => { - const id = `Activity_${letter}`; - const links = [...flows.filter((flow) => flow[2] === id).map((flow) => `${flow[0]}`), ...flows.filter((flow) => flow[1] === id).map((flow) => `${flow[0]}`)]; - return `${links.join('')}`; - }), - ...flows.map(([id, source, target]) => ``), - ].join('\n'), - ); - - it("bpmn-cli's own glued flows vote for the file's glued prefix: six adds to an f1 file keep fAToB, and each splice renames", async () => { - let xml = readFileSync(join(HERE, 'fixtures', 'incremental', 'polish-rename.bpmn'), 'utf8'); - let prev = 'A'; - const renames: Array> = []; - for (const name of ['Pack', 'Weigh', 'Label', 'Notify', 'Bill', 'Archive']) { - const result = await applyToXml(xml, [{ op: 'add', kind: 'task', name, after: prev, as: '$t' }], { layout: false }); - renames.push(result.result.renamed ?? {}); - prev = result.result.aliases!.$t!; - xml = result.xml; - } - // before: four f flows of nine after the fifth add, the file read as Flow_; the sixth kept fBillTo... on Archive -> C - // and named the new flow Flow_ArchiveToShipTheGoodsToTheCustomer - expect(renames.at(-1)).toEqual({ fBillToShipTheGoodsToTheCustomer: 'fBillToArchive' }); - expect([...xml.matchAll(/ match[1])).toEqual(['f1', 'f2', 'fPackToWeigh', 'fWeighToLabel', 'fLabelToNotify', 'fNotifyToBill', 'fBillToArchive', 'fArchiveToShipTheGoodsToTheCustomer', 'f3', 'f4']); - expect((await Doc.fromXml(xml)).ownIdStyle.info().sequenceFlow).toEqual({ form: 'named', prefix: 'f', sep: '' }); - }); - - it.each([ - ['snake_case', ['task_pack_goods', 'task_check_order', 'task_ship_goods'], ['fweigh_parcel_to_label_parcel', 'flabel_parcel_to_notify_customer', 'fnotify_customer_to_bill_customer', 'fbill_customer_to_archive_order', 'farchive_order_to_close_case', 'fclose_case_to_event'], { farchive_order_to_event: 'farchive_order_to_close_case' }], - ['Pascal_Snake', ['Task_Pack_Goods', 'Task_Check_Order', 'Task_Ship_Goods'], ['fWeigh_Parcel_To_Label_Parcel', 'fLabel_Parcel_To_Notify_Customer', 'fNotify_Customer_To_Bill_Customer', 'fBill_Customer_To_Archive_Order', 'fArchive_Order_To_Close_Case', 'fClose_Case_To_Event'], { fArchive_Order_To_Event: 'fArchive_Order_To_Close_Case' }], - ])("in a %s body too: bpmn-cli's glued flows (with `_`) vote for f, six adds keep it (eight with ids kept)", async (_body, [firstId, secondId, thirdId], glued, last) => { - const ends: Array<[string, string, string]> = [['f1', 'start_event', firstId!], ['f2', firstId!, secondId!], ['f3', secondId!, thirdId!], ['f4', thirdId!, 'end_event']]; - const links = (id: string): string => [...ends.filter((flow) => flow[2] === id).map((flow) => `${flow[0]}`), ...ends.filter((flow) => flow[1] === id).map((flow) => `${flow[0]}`)].join(''); - const start = definitionsXml( - [ - `${links('start_event')}`, - ...[firstId!, secondId!, thirdId!].map((id, taskIndex) => `${links(id)}`), - `${links('end_event')}`, - ...ends.map(([id, source, target]) => ``), - ].join('\n'), - ); - /** Adds the tasks one by one, each after the one before; the last command's renames and the file. */ - const walk = async (names: string[], keepFlowIds: boolean): Promise<{ renamed: Record; xml: string }> => { - let xml = start; - let prev = thirdId!; - let renamed: Record = {}; - for (const name of names) { - const result = await applyToXml(xml, [{ op: 'add', kind: 'task', name, after: prev, as: '$t' }], { layout: false, keepFlowIds }); - renamed = result.result.renamed ?? {}; - prev = result.result.aliases!.$t!; - xml = result.xml; - } - return { renamed, xml }; - }; - const names = ['Weigh parcel', 'Label parcel', 'Notify customer', 'Bill customer', 'Archive order', 'Close case']; - const { renamed, xml } = await walk(names, false); - // before: `fweigh_parcel_to_...` read as the prefix fweigh and voted nothing; after the fifth add the file read as - // Flow_, and the sixth renamed the file's own glued flow to Flow_archive_order_to_close_case - expect(renamed).toEqual(last); - expect([...xml.matchAll(/ match[1])).toEqual(['f1', 'f2', 'f3', 'f4', ...glued]); - expect((await Doc.fromXml(xml)).ownIdStyle.info().sequenceFlow).toEqual({ form: 'named', prefix: 'f', sep: '' }); - // with ids kept the spliced flows keep `..._to_event` on their new targets: they still name their source after f - const kept = await walk([...names, 'File case', 'Audit case'], true); - expect((await Doc.fromXml(kept.xml)).ownIdStyle.info().sequenceFlow).toEqual({ form: 'named', prefix: 'f', sep: '' }); - }); - - it('a glued flow votes only for a glued numbered prefix it starts with; a flow of a plain glued name, or of another prefix, does not', async () => { - const style = async (flows: string[]) => (await Doc.fromXml(tasks(flows.map((id, flowIndex) => [id, `Activity_${'ABCDEF'[flowIndex]}`, `Activity_${'ABCDEF'[flowIndex + 1]}`])))).ownIdStyle.info().sequenceFlow; - expect(await style(['f1', 'fBToC', 'fCToD', 'fDToE'])).toEqual({ form: 'named', prefix: 'f', sep: '' }); - // `flowBToC` starts with flow, not with f followed by an upper-case letter; F12 glues no words (F_BToC) - expect(await style(['f1', 'f2', 'flowCToD', 'flowDToE', 'flowEToF'])).toEqual({ form: 'named', prefix: 'Flow' }); - expect(await style(['F1', 'FBToC', 'FCToD', 'FDToE'])).toEqual({ form: 'named', prefix: 'Flow' }); - // with `_`: where it is the id bpmn-cli gives its ends after f in snake_case / Pascal_Snake; `flow_c_to_d` is not - // (it would be fc_to_d), it counts for the prefix flow - expect(await style(['f1', 'fb_to_c', 'fc_to_d', 'fd_to_e'])).toEqual({ form: 'named', prefix: 'f', sep: '' }); - expect(await style(['f1', 'fB_To_C', 'fC_To_D', 'fD_To_E'])).toEqual({ form: 'named', prefix: 'f', sep: '' }); - expect(await style(['f1', 'f2', 'flow_c_to_d', 'flow_d_to_e', 'flow_e_to_f'])).toEqual({ form: 'named', prefix: 'flow' }); - }); - - it('a tie between flow prefixes goes to the one the flows use most, then alphabetically, never by declaration order', async () => { - const fFlows: Array<[string, string, string]> = [['F1', 'Activity_A', 'Activity_B'], ['F2', 'Activity_B', 'Activity_C']]; - const gFlows: Array<[string, string, string]> = [['G1', 'Activity_C', 'Activity_D'], ['G2', 'Activity_D', 'Activity_E']]; - const underscoredFlows: Array<[string, string, string]> = [['F_EToF', 'Activity_E', 'Activity_F']]; - for (const flows of [[...fFlows, ...gFlows, ...underscoredFlows], [...gFlows, ...fFlows, ...underscoredFlows], [...underscoredFlows, ...gFlows, ...fFlows]]) { - const xml = tasks(flows); - expect((await Doc.fromXml(xml)).ownIdStyle.info().sequenceFlow).toEqual({ form: 'named', prefix: 'F', sep: '' }); - // before: with G1, G2 declared first G won, F_EToF kept its id on E -> Log, and the new flow was G_LogToF - const cs = runOps(await Doc.fromXml(xml), [{ op: 'add', kind: 'task', name: 'Log', after: 'Activity_E' }]); - expect(cs.renames).toEqual([expect.objectContaining({ from: 'F_EToF', to: 'F_EToLog' })]); - expect(cs.created.map((created) => created.id)).toEqual(['Activity_Log', 'F_LogToF']); - } - // nothing tells them apart: the alphabetically first - const tied = [...gFlows, ...fFlows]; - expect((await Doc.fromXml(tasks(tied))).ownIdStyle.info().sequenceFlow).toEqual({ form: 'named', prefix: 'F', sep: '' }); - // two Camunda Modeler versions: SequenceFlow_ and Flow_ hashes, tied; SequenceFlow_ names one more flow - const hashes = tasks([['Flow_0aaaaaa', 'Activity_A', 'Activity_B'], ['Flow_1bbbbbb', 'Activity_B', 'Activity_C'], ['SequenceFlow_0cccccc', 'Activity_C', 'Activity_D'], ['SequenceFlow_1dddddd', 'Activity_D', 'Activity_E'], ['SequenceFlow_EToF', 'Activity_E', 'Activity_F']]); - expect((await Doc.fromXml(hashes)).ownIdStyle.info().sequenceFlow).toEqual({ form: 'named', prefix: 'SequenceFlow' }); - // a glued prefix counts its flows too: f1, f2 against sf_1, sf_2 is a tie, f the alphabetically first (before: sf - // whenever it was declared first) - const chainOf = (ids: string[]): string => tasks(ids.map((id, flowIndex) => [id, `Activity_${'ABCDEF'[flowIndex]}`, `Activity_${'ABCDEF'[flowIndex + 1]}`])); - for (const ids of [['f1', 'f2', 'sf_1', 'sf_2'], ['sf_1', 'sf_2', 'f1', 'f2']]) expect((await Doc.fromXml(chainOf(ids))).ownIdStyle.info().sequenceFlow).toEqual({ form: 'named', prefix: 'f', sep: '' }); - // a tie between two forms under one prefix goes to the one that names the ends (before: hashes declared first, named) - for (const ids of [['Flow_0aaaaaa', 'Flow_1bbbbbb', 'Flow_Activity_C_Activity_D', 'Flow_Activity_D_Activity_E'], ['Flow_Activity_A_Activity_B', 'Flow_Activity_B_Activity_C', 'Flow_0cccccc', 'Flow_1dddddd']]) { - expect((await Doc.fromXml(chainOf(ids))).ownIdStyle.info().sequenceFlow).toEqual({ form: 'idPair', prefix: 'Flow' }); - } - }); - - it('a tie between the scopes of one prefix and form keeps the first one counted, as before (such a file does not change)', async () => { - const xml = readFileSync(join(HERE, 'fixtures', 'mixed-styles', 'scope-tie.bpmn'), 'utf8'); - expect((await Doc.fromXml(xml)).ownIdStyle.info().sequenceFlow).toEqual({ form: 'scopedTo', prefix: 'Flow', scope: 'Ord' }); - const result = await applyToXml(xml, [{ op: 'add', kind: 'task', name: 'Pack', after: 'Activity_Check' }], { layout: false }); - expect(result.result.renamed).toEqual({ Flow_Ord_CheckToShip: 'Flow_Ord_CheckToPack' }); - expect(result.result.created.map((created) => created.id)).toEqual(['Activity_Pack', 'Flow_Ord_PackToShip']); - // a flow under the other scope still names its ends: it follows them into the scope new flows there take (before: - // Flow_Bil_InvoiceToBook stayed on Invoice -> Weigh) - const split = await applyToXml(xml, [{ op: 'add', kind: 'task', name: 'Weigh', flow: 'Flow_Bil_InvoiceToBook' }], { layout: false }); - expect(split.result.renamed).toEqual({ Flow_Bil_InvoiceToBook: 'Flow_Ord_InvoiceToWeigh' }); - expect(split.result.created.map((created) => created.id)).toEqual(['Activity_Weigh', 'Flow_Ord_WeighToBook']); - const flows = [...xml.matchAll(/ {4} match[0]); - const billingFirst = xml.replace(flows.join(''), [...flows.slice(2), ...flows.slice(0, 2)].join('')); - expect((await Doc.fromXml(billingFirst)).ownIdStyle.info().sequenceFlow).toEqual({ form: 'scopedTo', prefix: 'Flow', scope: 'Bil' }); - }); - - it('a flow named after its ends under a prefix that lost the vote still follows them (renamed; with ids kept W_FLOW_ID_STALE)', async () => { - // SequenceFlow_ hashes outvote Flow_ ones: Flow_EToF (bpmn-cli's, from before) still names its ends - const xml = tasks([['SequenceFlow_0aaaaaa', 'Activity_A', 'Activity_B'], ['SequenceFlow_1bbbbbb', 'Activity_B', 'Activity_C'], ['SequenceFlow_0cccccc', 'Activity_C', 'Activity_D'], ['Flow_1dddddd', 'Activity_D', 'Activity_E'], ['Flow_EToF', 'Activity_E', 'Activity_F']]); - expect((await Doc.fromXml(xml)).ownIdStyle.info().sequenceFlow).toEqual({ form: 'named', prefix: 'SequenceFlow' }); - const cs = runOps(await Doc.fromXml(xml), [{ op: 'add', kind: 'task', name: 'Log', after: 'Activity_E' }]); - expect(cs.renames).toEqual([expect.objectContaining({ from: 'Flow_EToF', to: 'SequenceFlow_EToLog' })]); - const keep = await Doc.fromXml(xml); - keep.followFlowEnds = false; - const kept = runOps(keep, [{ op: 'add', kind: 'task', name: 'Log', after: 'Activity_E' }]); - expect(kept.warnings.filter((warning) => warning.code === 'W_FLOW_ID_STALE').map((warning) => warning.element)).toEqual(['Flow_EToF']); - // and a flow of another form the file's flows show (idPair under SF_, outvoted by named Flow_ flows) - const forms = tasks([['Flow_AToB', 'Activity_A', 'Activity_B'], ['Flow_BToC', 'Activity_B', 'Activity_C'], ['Flow_CToD', 'Activity_C', 'Activity_D'], ['SF_Activity_D_Activity_E', 'Activity_D', 'Activity_E'], ['SF_Activity_E_Activity_F', 'Activity_E', 'Activity_F']]); - expect((await Doc.fromXml(forms)).ownIdStyle.info().sequenceFlow).toEqual({ form: 'named', prefix: 'Flow' }); - const moved = runOps(await Doc.fromXml(forms), [{ op: 'add', kind: 'task', name: 'Log', after: 'Activity_E' }]); - expect(moved.renames).toEqual([expect.objectContaining({ from: 'SF_Activity_E_Activity_F', to: 'Flow_EToLog' })]); - }); -}); - -describe('flow ids kept (keepFlowIds): a stale id is recognised in every flow form', () => { - /** Flow__To flows. */ - const SCOPED = chain( - [ - ['startEvent', 'startEvent_KotOrder', 'Order placed'], - ['serviceTask', 'serviceTask_KotO_ValidatePayment', 'Validate payment'], - ['serviceTask', 'serviceTask_KotO_ReserveInventory', 'Reserve inventory'], - ['userTask', 'userTask_KotO_ConfirmShipment', 'Confirm shipment'], - ['endEvent', 'endEvent_KotOrder', 'Order done'], - ], - ['Flow_KotO_StartToValidate', 'Flow_KotO_ValidateToReserve', 'Flow_KotO_ReserveToConfirm', 'Flow_KotO_ConfirmToEnd'], - ); - /** The default named form, a second flow between the same pair with a collision suffix. */ +describe('flow ids kept (keepFlowIds): a stale id is recognised in the current flow form', () => { + /** A second flow between the same pair with a collision suffix. */ const SUFFIXED = definitionsXml(` - Flow_StartToOk - Flow_StartToOkFlow_OkToBookFlow_OkToBook_2 - Flow_OkToBookFlow_OkToBook_2 - - - `); + flow_startToOk + flow_startToOkflow_okToBookflow_okToBook2 + flow_okToBookflow_okToBook2 + + + `); /** `old -> new` of the renames of the default, and `flow -> suggested id` of the W_FLOW_ID_STALE with ids kept. */ async function bothModes(xml: string, ops: EditCommand[]): Promise<{ renamed: string[]; stale: string[] }> { @@ -662,12 +196,10 @@ describe('flow ids kept (keepFlowIds): a stale id is recognised in every flow fo return { renamed, stale }; } - it('the suggestion is the id the default rename gives, in the file\'s form (named, stemTo, idPair, scopedTo, a suffixed id)', async () => { + it('the suggestion is the id the default rename gives (also for a suffixed id)', async () => { const cases: Array<[string, EditCommand, string]> = [ [CAMEL, { op: 'add', kind: 'userTask', name: 'Pack goods', after: 'serviceTask_checkStock' }, 'flow_checkStockToApproveOrder -> flow_checkStockToPackGoods'], - [SNAKE, { op: 'add', kind: 'serviceTask', name: 'Pack goods', after: 'Task_check_risk' }, 'Flow_Task_check_risk_Gateway_risk_ok -> Flow_Task_check_risk_Task_pack_goods'], - [SCOPED, { op: 'add', kind: 'userTask', id: 'userTask_KotO_ReviewOrder', name: 'Review order', after: 'serviceTask_KotO_ReserveInventory' }, 'Flow_KotO_ReserveToConfirm -> Flow_KotO_ReserveToReview'], - [SUFFIXED, { op: 'add', kind: 'task', name: 'Review', flow: 'Flow_OkToBook_2' }, 'Flow_OkToBook_2 -> Flow_OkToReview'], + [SUFFIXED, { op: 'add', kind: 'task', name: 'Review', flow: 'flow_okToBook2' }, 'flow_okToBook2 -> flow_okToReview'], ]; for (const [xml, op, line] of cases) { const { renamed, stale } = await bothModes(xml, [op]); @@ -676,17 +208,10 @@ describe('flow ids kept (keepFlowIds): a stale id is recognised in every flow fo } }); - it('an end `set id=` renamed earlier in the batch still names its flows in every flow form (the id it had: stemTo, idPair, scopedTo)', async () => { - const cases: Array<[string, EditCommand[], string]> = [ - [CAMEL, [{ op: 'set', id: 'serviceTask_checkStock', values: { id: 'serviceTask_verifyStock' } }, { op: 'add', kind: 'userTask', name: 'Pack goods', after: 'serviceTask_verifyStock' }], 'flow_checkStockToApproveOrder -> flow_verifyStockToPackGoods'], - [SNAKE, [{ op: 'set', id: 'Task_check_risk', values: { id: 'Task_assess_risk' } }, { op: 'add', kind: 'serviceTask', name: 'Pack goods', after: 'Task_assess_risk' }], 'Flow_Task_check_risk_Gateway_risk_ok -> Flow_Task_assess_risk_Task_pack_goods'], - [SCOPED, [{ op: 'set', id: 'userTask_KotO_ConfirmShipment', values: { id: 'userTask_KotO_ApproveShipment' } }, { op: 'add', kind: 'userTask', id: 'userTask_KotO_ReviewOrder', name: 'Review order', before: 'userTask_KotO_ApproveShipment' }], 'Flow_KotO_ReserveToConfirm -> Flow_KotO_ReserveToReview'], - ]; - for (const [xml, ops, line] of cases) { - const { renamed, stale } = await bothModes(xml, ops); - expect(renamed, line).toEqual([line]); - expect(stale, line).toEqual([line]); - } + it('an end `set id=` renamed earlier in the batch still names its flows (the id it had)', async () => { + const ops: EditCommand[] = [{ op: 'set', id: 'serviceTask_checkStock', values: { id: 'serviceTask_verifyStock' } }, { op: 'add', kind: 'userTask', name: 'Pack goods', after: 'serviceTask_verifyStock' }]; + const line = 'flow_checkStockToApproveOrder -> flow_verifyStockToPackGoods'; + expect(await bothModes(CAMEL, ops)).toEqual({ renamed: [line], stale: [line] }); }); it('a glued suffix with No after a digit still names an end `set` renamed earlier in the batch (flow_approveToStep1No2)', async () => { @@ -706,43 +231,42 @@ describe('flow ids kept (keepFlowIds): a stale id is recognised in every flow fo expect(await bothModes(xml, ops)).toEqual({ renamed: ['flow_approveToStep1No2 -> flow_reviewToCheck'], stale: ['flow_approveToStep1No2 -> flow_reviewToCheck'] }); }); - it('flow ids that do not name their ends neither follow nor go stale (hashes, numbers)', async () => { + it('flow ids that do not name their ends in the current form neither follow nor go stale (hashes, numbers, older forms)', async () => { for (const [xml, anchor] of [[MODELER, 'Activity_0k3x9qa'], [NUMBERED, 'Task_1'], [OLD_MODELER, 'Task_1bcd234']] as const) { expect(await bothModes(xml, [{ op: 'add', kind: 'task', name: 'Pack', after: anchor }])).toEqual({ renamed: [], stale: [] }); } + // Flow_AToB named its ends in earlier versions: it keeps its id now + const older = chain( + [ + ['task', 'Activity_A', 'A'], + ['task', 'Activity_B', 'B'], + ], + ['Flow_AToB'], + ); + expect(await bothModes(older, [{ op: 'add', kind: 'task', name: 'Log', after: 'Activity_A' }])).toEqual({ renamed: [], stale: [] }); }); - it('two flows that end up between the same ends get the id and the _2 of the default rename, so every hint can be followed', async () => { + it('two flows that end up between the same ends get the id and the suffix of the default rename, so every hint can be followed', async () => { const joined = definitionsXml(` - Flow_StartToOk - Flow_StartToOkFlow_OkToAFlow_OkToB - Flow_OkToAFlow_AToJoin - Flow_OkToBFlow_BToJoin - Flow_AToJoinFlow_BToJoin - - - - - `); - const expected = ['Flow_OkToA -> Flow_OkToJoin', 'Flow_OkToB -> Flow_OkToJoin_2']; - for (const ops of [[{ op: 'remove', ids: ['Activity_A', 'Activity_B'] }], [{ op: 'remove', ids: ['Activity_A'] }, { op: 'remove', ids: ['Activity_B'] }]] as EditCommand[][]) { + flow_startToOk + flow_startToOkflow_okToAflow_okToB + flow_okToAflow_aToJoin + flow_okToBflow_bToJoin + flow_aToJoinflow_bToJoin + + + + + `); + const expected = ['flow_okToA -> flow_okToJoin', 'flow_okToB -> flow_okToJoin2']; + for (const ops of [[{ op: 'remove', ids: ['task_a', 'task_b'] }], [{ op: 'remove', ids: ['task_a'] }, { op: 'remove', ids: ['task_b'] }]] as EditCommand[][]) { expect(await bothModes(joined, ops), JSON.stringify(ops)).toEqual({ renamed: expected, stale: expected }); } const doc = await Doc.fromXml(joined); doc.followFlowEnds = false; - runOps(doc, [{ op: 'remove', ids: ['Activity_A', 'Activity_B'] }]); + runOps(doc, [{ op: 'remove', ids: ['task_a', 'task_b'] }]); runOps(doc, expected.map((line) => ({ op: 'set', id: line.split(' -> ')[0]!, values: { id: line.split(' -> ')[1]! } }))); - expect(flowBetween(doc, 'Gateway_Ok', 'Gateway_Join')).toMatch(/^Flow_OkToJoin(_2)?$/); - }); - - it('the id style is the file\'s as read, also when the first op removes flows it is learned from', async () => { - const xml = chain( - [['startEvent', 'Start', 'Start'], ['task', 'Check', 'Check'], ['task', 'Book', 'Book'], ['endEvent', 'Done', 'Done']], - ['F_StartToCheck', 'F_CheckToBook', 'F_BookToDone'], - ); - for (const ops of [[{ op: 'remove', ids: ['Start', 'Book'] }], [{ op: 'remove', ids: ['F_StartToCheck'] }, { op: 'remove', ids: ['Book'] }]] as EditCommand[][]) { - expect(await bothModes(xml, ops), JSON.stringify(ops)).toEqual({ renamed: ['F_CheckToBook -> F_CheckToDone'], stale: ['F_CheckToBook -> F_CheckToDone'] }); - } + expect(flowBetween(doc, 'gateway_ok', 'gateway_join')).toMatch(/^flow_okToJoin2?$/); }); }); @@ -758,27 +282,27 @@ describe('speaking ids on two branches of a file (independent edits)', () => { ); it('two edits of one file at different places never share a new id', async () => { - const left = await created(base, [{ op: 'add', kind: 'task', name: 'Check address', after: 'Activity_A' }, { op: 'add', kind: 'parallelGateway', after: 'Activity_CheckAddress' }]); - const right = await created(base, [{ op: 'add', kind: 'task', name: 'Archive', after: 'Activity_B' }, { op: 'add', kind: 'parallelGateway', after: 'Activity_Archive' }]); + const left = await created(base, [{ op: 'add', kind: 'task', name: 'Check address', after: 'Activity_A' }, { op: 'add', kind: 'parallelGateway', after: 'task_checkAddress' }]); + const right = await created(base, [{ op: 'add', kind: 'task', name: 'Archive', after: 'Activity_B' }, { op: 'add', kind: 'parallelGateway', after: 'task_archive' }]); expect(left.ids.filter((id) => right.ids.includes(id))).toEqual([]); // the merged file has no duplicate id: every new id of the right branch is free on the left for (const id of right.ids) expect(left.doc.ids.has(id)).toBe(false); // the flow that CheckAddress -> B got when it was created names its new ends once the gateway is spliced in; // the result lists every id as it is at the end of the batch - expect(left.ids).toEqual(['Activity_CheckAddress', 'Flow_CheckAddressToAfterCheckAddress', 'Gateway_AfterCheckAddress', 'Flow_AfterCheckAddressToB']); - expect(flowBetween(left.doc, 'Activity_CheckAddress', 'Gateway_AfterCheckAddress')).toBe('Flow_CheckAddressToAfterCheckAddress'); + expect(left.ids).toEqual(['task_checkAddress', 'flow_checkAddressToAfterCheckAddress', 'gateway_afterCheckAddress', 'flow_afterCheckAddressToB']); + expect(flowBetween(left.doc, 'task_checkAddress', 'gateway_afterCheckAddress')).toBe('flow_checkAddressToAfterCheckAddress'); }); it('new files name their flows after their ends', async () => { const doc = Doc.create({ processName: 'P' }); const cs = runOps(doc, [ { op: 'add', kind: 'startEvent', name: 'S' }, - { op: 'add', kind: 'task', name: 'T', after: 'Event_S' }, + { op: 'add', kind: 'task', name: 'T', after: 'startEvent_s' }, ]); - expect(cs.created.map((created) => created.id)).toEqual(['Event_S', 'Activity_T', 'Flow_SToT']); + expect(cs.created.map((created) => created.id)).toEqual(['startEvent_s', 'task_t', 'flow_sToT']); }); - it('files that number their flows get speaking flows with their prefix (Flow_12 -> Flow_BToEnd)', async () => { + it('files that number their flows get speaking flows (Flow_12 -> flow_bToEnd)', async () => { const xml = chain( [ ['startEvent', 'Event_Start', 'Start'], @@ -788,52 +312,49 @@ describe('speaking ids on two branches of a file (independent edits)', () => { ['Flow_11', 'Flow_12'], ); const result = await created(xml, [{ op: 'add', kind: 'task', name: 'B', after: 'Activity_A' }]); - expect(result.ids).toEqual(['Activity_B', 'Flow_BToEnd']); + expect(result.ids).toEqual(['task_b', 'flow_bToEnd']); // a numbered flow says nothing about its ends: it keeps its id when a splice changes them - expect(flowBetween(result.doc, 'Activity_A', 'Activity_B')).toBe('Flow_12'); + expect(flowBetween(result.doc, 'Activity_A', 'task_b')).toBe('Flow_12'); }); }); describe('German names', () => { - it('transliterates umlauts as ae / oe / ue / ss in every style', async () => { + it('transliterates umlauts as ae / oe / ue / ss', async () => { const doc = Doc.create({ processName: 'Prüfung' }); - expect(doc.processes()[0]!.get('id')).toBe('Process_Pruefung'); + expect(doc.processes()[0]!.get('id')).toBe('pruefung'); const cs = runOps(doc, [{ op: 'add', kind: 'task', name: 'Größe prüfen' }]); - expect(cs.created[0]!.id).toBe('Activity_GroessePruefen'); + expect(cs.created[0]!.id).toBe('task_groessePruefen'); const camel = runOps(await Doc.fromXml(CAMEL), [{ op: 'add', kind: 'userTask', name: 'Änderung übernehmen', in: 'Process_1' }]); expect(camel.created[0]!.id).toBe('userTask_aenderungUebernehmen'); - const snake = runOps(await Doc.fromXml(SNAKE), [{ op: 'add', kind: 'userTask', name: 'Straße ändern', in: 'Process_1' }]); - expect(snake.created[0]!.id).toBe('Task_strasse_aendern'); }); it('E_NOT_FOUND suggests the id for another spelling of an umlaut', async () => { const doc = Doc.create({ processName: 'P' }); runOps(doc, [{ op: 'add', kind: 'task', name: 'Prüfung' }]); - for (const query of ['Activity_Prufung', 'Activity_Prüfung']) { + for (const query of ['task_prufung', 'task_prüfung']) { let candidates: string[] = []; try { doc.require(query); } catch (err) { candidates = (err as { details: { candidates: string[] } }).details.candidates; } - expect(candidates).toContain('Activity_Pruefung'); + expect(candidates).toContain('task_pruefung'); } }); }); describe('documentation', () => { it('bpmn kinds --json describes the id conventions', () => { - const ids = kindsJson()['ids'] as { bodies: Record; flowForms: Record; length: string }; - expect(Object.keys(ids.bodies).sort()).toEqual(['camel', 'pascal', 'pascalSnake', 'snake']); + const ids = kindsJson()['ids'] as { bodies: Record; length: string }; + expect(Object.keys(ids.bodies).sort()).toEqual(['camel', 'kebab', 'pascal', 'snake']); expect(ids.length).toMatch(/^at most 64 characters, a collision suffix included; a name gives at most 40 characters of words \(a single longer word is cut at 40\), and an id within 64 is not cut again; a longer one loses whole words/); - expect(Object.keys(ids.flowForms).sort()).toEqual(['idPair', 'idSnake', 'named', 'scopedTo', 'stemSnake', 'stemTo']); - expect(guideText()).toContain('Ids speak and follow the file'); + expect(guideText()).toContain('Ids speak and follow element-id-naming'); expect(JSON.stringify(kindsJson()['ids'])).not.toMatch(/hash of|base-36/); }); }); describe('ids through the pipeline', () => { - it('a write reports the generated ids of the file style', async () => { + it('a write reports the generated ids', async () => { const doc = await Doc.fromXml(CAMEL); const result = await mutateDoc(doc, [{ op: 'add', kind: 'userTask', name: 'Pack goods', after: 'serviceTask_checkStock' }], { dryRun: true, layout: false }); expect(result.changes.created.map((created) => created.id)).toEqual(['userTask_packGoods', 'flow_packGoodsToApproveOrder']); diff --git a/test/domain/document/ids/speaking-id-edge-cases.test.ts b/test/domain/document/ids/speaking-id-edge-cases.test.ts index abe6e01b..1e52df5c 100644 --- a/test/domain/document/ids/speaking-id-edge-cases.test.ts +++ b/test/domain/document/ids/speaking-id-edge-cases.test.ts @@ -1,7 +1,7 @@ /** * Speaking id edge cases: an anchor name with a number keeps its speaking * stem, a data reference and the element it stands for get distinct - * speaking ids in a file with type-named prefixes, and DI ids derived from + * speaking ids, and DI ids derived from * long element ids keep the 64 character cap. Synthetic models only. */ import { describe, expect, it } from 'vitest'; @@ -11,8 +11,8 @@ import { speakingStem } from '../../../../src/domain/document/utils/id-words.js' const flowIds = (xml: string): string[] => [...xml.matchAll(/ match[1]!); const allIds = (xml: string): string[] => [...xml.matchAll(/ id="([^"]+)"/g)].map((match) => match[1]!); -/** a flow id with a side that is only a word for a kind (Flow_CheckToGateway, Flow_GatewayToAfterGateway) */ -const GENERIC_SIDE = /(^Flow_|To)(Gateway|AfterGateway|Join|Task|Timer|Catch)(To|$|_\d+$)/; +/** a flow id with a side that is only a word for a kind (flow_checkToGateway, flow_gatewayToAfterGateway) */ +const GENERIC_SIDE = /(^flow_|To)(gateway|Gateway|AfterGateway|Join|Task|Timer|Catch)(To|$|\d+$)/; describe('an anchor name with a number keeps its speaking stem', () => { it('speakingStem keeps names with numbers, not machine ids', () => { @@ -29,7 +29,7 @@ describe('an anchor name with a number keeps its speaking stem', () => { for (const name of ['Check 2024', 'Order 01 001', 'BP 01 001 UI']) { it(`the split after "${name}": the flows and the unnamed task name the gateways, not their kind`, async () => { const ops = [ - { op: 'add', kind: 'startEvent', name: 'Start', in: 'Process_Bp', as: '$s' }, + { op: 'add', kind: 'startEvent', name: 'Start', in: 'bp', as: '$s' }, { op: 'add', kind: 'userTask', name, after: '$s', as: '$u' }, { op: 'add', kind: 'endEvent', name: 'Done', after: '$u' }, { op: 'split', after: '$u', kind: 'parallelGateway', branches: [{ nodes: [{ kind: 'serviceTask', name: 'Reserve stock' }] }, { nodes: [{ kind: 'serviceTask' }] }] }, @@ -37,7 +37,7 @@ describe('an anchor name with a number keeps its speaking stem', () => { const applied = await applyToXml((await newXml({ processName: 'Bp' })).xml, ops, { layout: false }); const flows = flowIds(applied.xml); for (const id of flows) expect(id).not.toMatch(GENERIC_SIDE); - expect(allIds(applied.xml).filter((id) => /^Activity_/.test(id))).not.toContain('Activity_AfterGateway'); + expect(allIds(applied.xml).filter((id) => /^serviceTask_/.test(id))).not.toContain('serviceTask_afterGateway'); expect(new Set(flows).size).toBe(flows.length); expect(applied.result.warnings.added.map((warning) => warning.code)).not.toContain('W_ID_SUFFIXED'); }); @@ -45,7 +45,7 @@ describe('an anchor name with a number keeps its speaking stem', () => { it('the exact ids after Check 2024', async () => { const ops = [ - { op: 'add', kind: 'startEvent', name: 'Start', in: 'Process_Bp', as: '$s' }, + { op: 'add', kind: 'startEvent', name: 'Start', in: 'bp', as: '$s' }, { op: 'add', kind: 'userTask', name: 'Check 2024', after: '$s', as: '$u' }, { op: 'add', kind: 'endEvent', name: 'Done', after: '$u' }, { op: 'split', after: '$u', kind: 'parallelGateway', branches: [{ nodes: [{ kind: 'serviceTask', name: 'Reserve stock' }] }, { nodes: [{ kind: 'serviceTask' }] }] }, @@ -53,19 +53,19 @@ describe('an anchor name with a number keeps its speaking stem', () => { const applied = await applyToXml((await newXml({ processName: 'Bp' })).xml, ops, { layout: false }); expect(flowIds(applied.xml).sort()).toEqual( [ - 'Flow_StartToCheck2024', - 'Flow_Check2024ToAfterCheck2024', - 'Flow_AfterCheck2024ToReserveStock', - 'Flow_AfterCheck2024ToServiceTaskAfterCheck2024', - 'Flow_ReserveStockToCheck2024Join', - 'Flow_ServiceTaskAfterCheck2024ToCheck2024Join', - 'Flow_Check2024JoinToDone', + 'flow_startToCheck2024', + 'flow_check2024ToAfterCheck2024', + 'flow_afterCheck2024ToReserveStock', + 'flow_afterCheck2024ToAfterCheck2024Gateway', + 'flow_reserveStockToCheck2024Join', + 'flow_afterCheck2024GatewayToCheck2024Join', + 'flow_check2024JoinToDone', ].sort(), ); }); }); -/** A camelCase file whose prefixes are the element types (startEvent_, serviceTask_), without data. */ +/** A file in the defaults of element-id-naming (startEvent_, serviceTask_), without data. */ const CAMEL = ` @@ -78,35 +78,17 @@ const CAMEL = ` `; describe('a data reference and the element it stands for get distinct speaking ids', () => { - it('type-named prefixes: dataObjectReference_invoice and dataObject_invoice, no suffix', async () => { + it('dataObject_invoice and its backing dataObjectItem_invoice, no suffix', async () => { const applied = await applyToXml(CAMEL, [{ op: 'add', kind: 'dataObjectReference', name: 'Invoice', in: 'process_import', as: '$d' }, { op: 'connect', source: 'serviceTask_importArticles', target: '$d' }], { layout: false }); - expect(applied.xml).toContain(''); - expect(applied.xml).toContain(''); - expect(applied.result.aliases).toEqual({ $d: 'dataObjectReference_invoice' }); + expect(applied.xml).toContain(''); + expect(applied.xml).toContain(''); + expect(applied.result.aliases).toEqual({ $d: 'dataObject_invoice' }); expect(applied.result.warnings.added.map((warning) => warning.code)).not.toContain('W_ID_SUFFIXED'); }); - it('type-named prefixes: dataStoreReference_invoice and dataStore_invoice, no suffix', async () => { + it('dataStore_invoice and its backing dataStoreItem_invoice, no suffix', async () => { const applied = await applyToXml(CAMEL, [{ op: 'add', kind: 'dataStoreReference', name: 'Invoice', in: 'process_import' }], { layout: false }); - expect(applied.xml).toContain(''); - expect(applied.result.warnings.added.map((warning) => warning.code)).not.toContain('W_ID_SUFFIXED'); - }); - - it('a file that names references and data objects with one prefix: the data object says what it is, no suffix', async () => { - const xml = CAMEL.replace( - ' - - - - '); expect(applied.result.warnings.added.map((warning) => warning.code)).not.toContain('W_ID_SUFFIXED'); }); }); @@ -115,9 +97,9 @@ describe('DI ids derived from long element ids keep the 64 character cap', () => for (const style of ['Shape_/Edge_', 'BPMNShape_/BPMNEdge_', '_di']) { it(`a file whose DI ids are ${style}`, async () => { const base = await applyToXml((await newXml({ processName: 'Meter' })).xml, [ - { op: 'add', kind: 'startEvent', name: 'Start', in: 'Process_Meter', as: '$s' }, + { op: 'add', kind: 'startEvent', name: 'Start', in: 'meter', as: '$s' }, { op: 'add', kind: 'userTask', name: '4-Augen-Prinzip: prüfen', after: '$s', as: '$a' }, - { op: 'add', kind: 'endEvent', name: 'Done', after: '$a' }, + { op: 'add', kind: 'endEvent', name: 'Done', after: 'userTask_userTask4AugenPrinzipPruefen' }, ]); const xml = style === 'Shape_/Edge_' @@ -126,7 +108,7 @@ describe('DI ids derived from long element ids keep the 64 character cap', () => ? base.xml.replace(/id="BPMN(?:Shape|Edge)_([^"]+)"/g, (_m, id: string) => `id="${id}_di"`) : base.xml; const applied = await applyToXml(xml, [ - { op: 'add', kind: 'userTask', name: '4-Augen-Prinzip: prüfen & freigeben (2. Stufe)?', after: 'Activity_4AugenPrinzipPruefen', as: '$q' }, + { op: 'add', kind: 'userTask', name: '4-Augen-Prinzip: prüfen & freigeben (2. Stufe)?', after: 'userTask_userTask4AugenPrinzipPruefen', as: '$q' }, { op: 'add', kind: 'serviceTask', name: '审批 订单', after: '$q' }, ]); const ids = allIds(applied.xml); @@ -136,7 +118,7 @@ describe('DI ids derived from long element ids keep the 64 character cap', () => const flow = flowIds(applied.xml).sort((left, right) => right.length - left.length)[0]!; expect(flow.length).toBeGreaterThan(MAX_ID - 'Edge_'.length); const edge = new RegExp(` { const outcome = await created(MODELER, [ { op: 'add', kind: 'serviceTask', name: 'Größe messen', after: 'Activity_0k3x9qa' }, { op: 'add', kind: 'boundaryEvent:timer', on: 'Activity_0k3x9qa', timer: 'PT1H' }, - { op: 'add', kind: 'endEvent', after: 'Event_TimerOnPruefung' }, - { op: 'add', kind: 'exclusiveGateway', before: 'Activity_GroesseMessen' }, + { op: 'add', kind: 'endEvent', after: 'event_timerOnPruefung' }, + { op: 'add', kind: 'exclusiveGateway', before: 'serviceTask_groesseMessen' }, { op: 'add', kind: 'subProcess', name: 'Nacharbeit', in: 'Process_1' }, - { op: 'add', kind: 'startEvent', in: 'Activity_Nacharbeit' }, + { op: 'add', kind: 'startEvent', in: 'subProcess_nacharbeit' }, ]); expect(outcome.ids).toEqual([ - 'Activity_GroesseMessen', + 'serviceTask_groesseMessen', // the end event says nothing about itself (a hash, no name): a word for its kind - 'Flow_GroesseMessenToEnd', - 'Event_TimerOnPruefung', + 'flow_groesseMessenToEnd', + 'event_timerOnPruefung', // an unnamed end is named by its own id's speaking part: its kind and place - 'Event_EndAfterTimerOnPruefung', - 'Flow_TimerOnPruefungToEndAfterTimerOnPruefung', - 'Gateway_BeforeGroesseMessen', - 'Flow_BeforeGroesseMessenToGroesseMessen', - 'Activity_Nacharbeit', - 'Event_StartInNacharbeit', + 'endEvent_afterTimerOnPruefung', + 'flow_timerOnPruefungToAfterTimerOnPruefung', + 'gateway_beforeGroesseMessen', + 'flow_beforeGroesseMessenToGroesseMessen', + 'subProcess_nacharbeit', + 'startEvent_inNacharbeit', ]); for (const id of outcome.ids) expect(id).not.toMatch(NOT_SPEAKING); // the hashed flows say nothing about their ends: they keep their ids where an edit changed the ends - expect(flowBetween(outcome.doc, 'Activity_0k3x9qa', 'Gateway_BeforeGroesseMessen')).toBe('Flow_1q2w3e4'); + expect(flowBetween(outcome.doc, 'Activity_0k3x9qa', 'gateway_beforeGroesseMessen')).toBe('Flow_1q2w3e4'); }); - it('two unnamed elements in the same place: the second gets _2 and W_ID_SUFFIXED, deterministically', async () => { + it('two unnamed elements in the same place: the second gets 2 and W_ID_SUFFIXED, deterministically', async () => { const ops: EditCommand[] = [ { op: 'add', kind: 'endEvent', in: 'Process_1' }, { op: 'add', kind: 'endEvent', in: 'Process_1' }, - { op: 'connect', source: 'Activity_0k3x9qa', target: 'Event_End' }, - { op: 'connect', source: 'Activity_0k3x9qa', target: 'Event_End' }, + { op: 'connect', source: 'Activity_0k3x9qa', target: 'endEvent_end' }, + { op: 'connect', source: 'Activity_0k3x9qa', target: 'endEvent_end' }, ]; const outcome = await created(MODELER, ops); - expect(outcome.ids).toEqual(['Event_End', 'Event_End_2', 'Flow_PruefungToEnd', 'Flow_PruefungToEnd_2']); - expect(outcome.warnings.filter((warning) => warning.startsWith('W_ID_SUFFIXED'))).toEqual(['W_ID_SUFFIXED Event_End_2', 'W_ID_SUFFIXED Flow_PruefungToEnd_2']); + expect(outcome.ids).toEqual(['endEvent_end', 'endEvent_end2', 'flow_pruefungToEnd', 'flow_pruefungToEnd2']); + expect(outcome.warnings.filter((warning) => warning.startsWith('W_ID_SUFFIXED'))).toEqual(['W_ID_SUFFIXED endEvent_end2', 'W_ID_SUFFIXED flow_pruefungToEnd2']); expect((await created(MODELER, ops)).ids).toEqual(outcome.ids); }); }); @@ -75,10 +75,10 @@ describe('speaking ids in a Camunda Modeler file', () => { async function drawnDefault(): Promise { const ops: EditCommand[] = [ { op: 'add', kind: 'startEvent', name: 'Start' }, - { op: 'add', kind: 'task', name: 'Check', after: 'Event_Start' }, - { op: 'add', kind: 'exclusiveGateway', name: 'Ok?', after: 'Activity_Check' }, - { op: 'add', kind: 'task', name: 'Book', after: 'Gateway_Ok', default: true }, - { op: 'add', kind: 'endEvent', name: 'Done', after: 'Activity_Book' }, + { op: 'add', kind: 'task', name: 'Check', after: 'startEvent_start' }, + { op: 'add', kind: 'exclusiveGateway', name: 'Ok?', after: 'task_check' }, + { op: 'add', kind: 'task', name: 'Book', after: 'gateway_ok', default: true }, + { op: 'add', kind: 'endEvent', name: 'Done', after: 'task_book' }, ]; return (await applyToXml((await newXml({ processName: 'P' })).xml, ops)).xml.replace(/BPMN(Shape|Edge)_([A-Za-z0-9_]+)/g, '$2_di'); } @@ -86,87 +86,71 @@ async function drawnDefault(): Promise { describe('flows whose ids name their ends follow them', () => { it('a splice renames the spliced flow, its DI edge and the default reference; the result says so', async () => { const xml = await drawnDefault(); - expect(xml).toContain(''); - expect(xml).toContain(''); - const outcome = await applyToXml(xml, [{ op: 'add', kind: 'task', name: 'Review', flow: 'Flow_OkToBook' }]); - expect(outcome.result.created.map((change) => change.id)).toEqual(['Activity_Review', 'Flow_ReviewToBook']); - expect(outcome.result.changed.map((change) => `${change.id}: ${change.detail}`)).toContain('Flow_OkToReview: Gateway_Ok -> Activity_Review (was -> Activity_Book) (renamed from Flow_OkToBook: its id named its old ends)'); - expect(outcome.xml).toContain(''); - expect(outcome.xml).toContain(''); - expect(outcome.xml).not.toContain('Flow_OkToBook'); + expect(xml).toContain(''); + expect(xml).toContain(''); + const outcome = await applyToXml(xml, [{ op: 'add', kind: 'task', name: 'Review', flow: 'flow_okToBook' }]); + expect(outcome.result.created.map((change) => change.id)).toEqual(['task_review', 'flow_reviewToBook']); + expect(outcome.result.changed.map((change) => `${change.id}: ${change.detail}`)).toContain('flow_okToReview: gateway_ok -> task_review (was -> task_book) (renamed from flow_okToBook: its id named its old ends)'); + expect(outcome.xml).toContain(''); + expect(outcome.xml).toContain(''); + expect(outcome.xml).not.toContain('flow_okToBook'); }); it('a bridge, a move and set target= rename it too; every other id stays', async () => { const xml = await drawnDefault(); - const removed = await applyToXml(xml, [{ op: 'remove', ids: ['Activity_Book'] }]); - expect(removed.result.changed.map((change) => change.id)).toContain('Flow_OkToDone'); - expect(removed.xml).toContain(''); - const retargeted = await applyToXml(xml, [{ op: 'set', id: 'Flow_BookToDone', values: { target: 'Activity_Check' } }]); - expect(retargeted.xml).toContain(''); - const moved = await applyToXml(xml, [{ op: 'move', ids: ['Activity_Book'], before: 'Activity_Check' }]); + const removed = await applyToXml(xml, [{ op: 'remove', ids: ['task_book'] }]); + expect(removed.result.changed.map((change) => change.id)).toContain('flow_okToDone'); + expect(removed.xml).toContain(''); + const retargeted = await applyToXml(xml, [{ op: 'set', id: 'flow_bookToDone', values: { target: 'task_check' } }]); + expect(retargeted.xml).toContain(''); + const moved = await applyToXml(xml, [{ op: 'move', ids: ['task_book'], before: 'task_check' }]); const ids = [...moved.xml.matchAll(/ `${match[1]} ${match[2]}->${match[3]}`); - expect(ids.sort()).toEqual(['Flow_BookToCheck Activity_Book->Activity_Check', 'Flow_CheckToOk Activity_Check->Gateway_Ok', 'Flow_OkToDone Gateway_Ok->Event_Done', 'Flow_StartToBook Event_Start->Activity_Book']); + expect(ids.sort()).toEqual(['flow_bookToCheck task_book->task_check', 'flow_checkToOk task_check->gateway_ok', 'flow_okToDone gateway_ok->endEvent_done', 'flow_startToBook startEvent_start->task_book']); // an explicit id that does not name its ends stays const own = await applyToXml(xml, [ - { op: 'connect', source: 'Gateway_Ok', target: 'Event_Done', id: 'Flow_Shortcut', name: 'skip' }, - { op: 'add', kind: 'task', name: 'Log', flow: 'Flow_Shortcut' }, + { op: 'connect', source: 'gateway_ok', target: 'endEvent_done', id: 'flow_shortcut', name: 'skip' }, + { op: 'add', kind: 'task', name: 'Log', flow: 'flow_shortcut' }, ]); - expect(own.xml).toContain(''); + expect(own.xml).toContain(''); }); it('a batch lists every id as it is at its end, also when a later op renamed it', async () => { const doc = Doc.create({ processName: 'P' }); const cs = runOps(doc, [ { op: 'add', kind: 'startEvent', name: 'Start' }, - { op: 'add', kind: 'endEvent', name: 'End', after: 'Event_Start' }, - { op: 'add', kind: 'task', name: 'Work', after: 'Event_Start' }, + { op: 'add', kind: 'endEvent', name: 'End', after: 'startEvent_start' }, + { op: 'add', kind: 'task', name: 'Work', after: 'startEvent_start' }, ]); - expect(cs.created.map((change) => change.id)).toEqual(['Event_Start', 'Event_End', 'Flow_StartToWork', 'Activity_Work', 'Flow_WorkToEnd']); - expect(doc.get('Flow_StartToEnd')).toBeUndefined(); - expect(flowBetween(doc, 'Event_Start', 'Activity_Work')).toBe('Flow_StartToWork'); + expect(cs.created.map((change) => change.id)).toEqual(['startEvent_start', 'endEvent_end', 'flow_startToWork', 'task_work', 'flow_workToEnd']); + expect(doc.get('flow_startToEnd')).toBeUndefined(); + expect(flowBetween(doc, 'startEvent_start', 'task_work')).toBe('flow_startToWork'); }); }); describe('the change report names a flow that took the id a renamed flow freed by its own id', () => { - /** Check -> Done: a new node with the id Activity_Check spliced after Check gives the flow into it Flow_CheckToCheck and the flow out of it Flow_CheckToDone, the id the spliced flow had. */ + /** Check -> Done: a new node with the id task_check spliced after Check gives the flow into it flow_checkToCheck and the flow out of it flow_checkToDone, the id the spliced flow had. */ const FREED = definitionsXml(` - Flow_CheckToDone - Flow_CheckToDone - `); - const splice: EditCommand = { op: 'add', kind: 'task', id: 'Activity_Check', after: 'Event_Check' }; + flow_checkToDone + flow_checkToDone + `); + const splice: EditCommand = { op: 'add', kind: 'task', id: 'task_check', after: 'startEvent_check' }; it('text, JSON and summary agree with the file, alone and in a batch', async () => { - for (const ops of [[splice], [{ op: 'set', id: 'Flow_CheckToDone', values: { name: 'go' } }, splice]] as EditCommand[][]) { + for (const ops of [[splice], [{ op: 'set', id: 'flow_checkToDone', values: { name: 'go' } }, splice]] as EditCommand[][]) { const outcome = await applyToXml(FREED, ops, { layout: false }); const doc = await Doc.fromXml(outcome.xml); - expect([flowBetween(doc, 'Event_Check', 'Activity_Check'), flowBetween(doc, 'Activity_Check', 'Event_Done')]).toEqual(['Flow_CheckToCheck', 'Flow_CheckToDone']); - expect(outcome.result.created.map((change) => `${change.id}: ${change.detail}`)).toEqual(['Activity_Check: after Event_Check', 'Flow_CheckToDone: Activity_Check -> Event_Done']); - expect(outcome.result.changed.map((change) => `${change.id}: ${change.detail}`)).toContain('Flow_CheckToCheck: Event_Check -> Activity_Check (was -> Event_Done) (renamed from Flow_CheckToDone: its id named its old ends)'); + expect([flowBetween(doc, 'startEvent_check', 'task_check'), flowBetween(doc, 'task_check', 'endEvent_done')]).toEqual(['flow_checkToCheck', 'flow_checkToDone']); + expect(outcome.result.created.map((change) => `${change.id}: ${change.detail}`)).toEqual(['task_check: after startEvent_check', 'flow_checkToDone: task_check -> endEvent_done']); + expect(outcome.result.changed.map((change) => `${change.id}: ${change.detail}`)).toContain('flow_checkToCheck: startEvent_check -> task_check (was -> endEvent_done) (renamed from flow_checkToDone: its id named its old ends)'); // the entry of the earlier op is about the spliced flow: it names its final id - if (ops.length === 2) expect(outcome.result.changed.map((change) => `${change.id}: ${change.detail}`)).toContain('Flow_CheckToCheck: name=go'); - expect(outcome.result.changed.map((change) => change.id)).not.toContain('Flow_CheckToDone'); - expect(outcome.result.renamed).toEqual({ Flow_CheckToDone: 'Flow_CheckToCheck' }); + if (ops.length === 2) expect(outcome.result.changed.map((change) => `${change.id}: ${change.detail}`)).toContain('flow_checkToCheck: name=go'); + expect(outcome.result.changed.map((change) => change.id)).not.toContain('flow_checkToDone'); + expect(outcome.result.renamed).toEqual({ flow_checkToDone: 'flow_checkToCheck' }); const text = renderMutation(outcome.result); - expect(text).toContain('created sequenceFlow Flow_CheckToDone - Activity_Check -> Event_Done\n'); + expect(text).toContain('created sequenceFlow flow_checkToDone - task_check -> endEvent_done\n'); const summary = mutationSummary(outcome.result); - expect([summary.created['sequenceFlow'], summary.changed, summary.renamed]).toEqual([['Flow_CheckToDone'], ['Flow_CheckToCheck'], { Flow_CheckToDone: 'Flow_CheckToCheck' }]); - expect(renderSummary(summary)).toMatch(/^created task: Activity_Check\ncreated sequenceFlow: Flow_CheckToDone\nchanged: Flow_CheckToCheck\nrenamed: Flow_CheckToDone -> Flow_CheckToCheck\n/); + expect([summary.created['sequenceFlow'], summary.changed, summary.renamed]).toEqual([['flow_checkToDone'], ['flow_checkToCheck'], { flow_checkToDone: 'flow_checkToCheck' }]); + expect(renderSummary(summary)).toMatch(/^created task: task_check\ncreated sequenceFlow: flow_checkToDone\nchanged: flow_checkToCheck\nrenamed: flow_checkToDone -> flow_checkToCheck\n/); } }); }); - -describe('speaking ids follow the file', () => { - it('a file that numbers its ids keeps its prefixes and separator, never its numbers', async () => { - const xml = definitionsXml(` - SF_1 - SF_1SF_2 - SF_2 - - `); - const outcome = await created(xml, [ - { op: 'add', kind: 'parallelGateway', after: 'Task_2' }, - { op: 'add', kind: 'task', name: 'Third', after: 'Gateway_AfterSecond' }, - ]); - expect(outcome.ids).toEqual(['Gateway_AfterSecond', 'SF_SecondToAfterSecond', 'Task_Third', 'SF_AfterSecondToThird']); - }); -}); diff --git a/test/domain/document/not-found.test.ts b/test/domain/document/not-found.test.ts index 9d783da4..bc308f79 100644 --- a/test/domain/document/not-found.test.ts +++ b/test/domain/document/not-found.test.ts @@ -14,9 +14,9 @@ async function invoice(): Promise { return ( await applyToXml((await newXml({ processName: 'Rechnung' })).xml, [ { op: 'add', kind: 'start', name: 'Rechnung eingegangen' }, - { op: 'add', kind: 'userTask', name: 'Check invoice', after: 'Event_RechnungEingegangen' }, - { op: 'add', kind: 'userTask', name: 'Prüfung', after: 'Activity_CheckInvoice' }, - { op: 'add', kind: 'end', name: 'Done', after: 'Activity_Pruefung' }, + { op: 'add', kind: 'userTask', name: 'Check invoice', after: 'startEvent_rechnungEingegangen' }, + { op: 'add', kind: 'userTask', name: 'Prüfung', after: 'userTask_checkInvoice' }, + { op: 'add', kind: 'end', name: 'Done', after: 'userTask_pruefung' }, ]) ).xml; } @@ -34,44 +34,44 @@ async function notFound(xml: string, ops: unknown): Promise { describe('E_NOT_FOUND candidates', () => { it('typos, another or no prefix, umlaut spellings: the meant id first', async () => { const doc = await Doc.fromXml(await invoice()); - expect(doc.suggest('Activity_ChekInvoice')[0]).toBe('Activity_CheckInvoice'); - expect(doc.suggest('Activity_CheckInvocie')[0]).toBe('Activity_CheckInvoice'); - expect(doc.suggest('Task_CheckInvoice')[0]).toBe('Activity_CheckInvoice'); - expect(doc.suggest('CheckInvoice')[0]).toBe('Activity_CheckInvoice'); - expect(doc.suggest('Task_ChekInvoice')[0]).toBe('Activity_CheckInvoice'); - expect(doc.suggest('Activity_Prüfung')[0]).toBe('Activity_Pruefung'); - expect(doc.suggest('activity_prufung')[0]).toBe('Activity_Pruefung'); + expect(doc.suggest('userTask_chekInvoice')[0]).toBe('userTask_checkInvoice'); + expect(doc.suggest('userTask_checkInvocie')[0]).toBe('userTask_checkInvoice'); + expect(doc.suggest('task_checkInvoice')[0]).toBe('userTask_checkInvoice'); + expect(doc.suggest('checkInvoice')[0]).toBe('userTask_checkInvoice'); + expect(doc.suggest('task_chekInvoice')[0]).toBe('userTask_checkInvoice'); + expect(doc.suggest('userTask_prüfung')[0]).toBe('userTask_pruefung'); + expect(doc.suggest('usertask_prufung')[0]).toBe('userTask_pruefung'); // flows too: their ids name their ends - expect(doc.suggest('Flow_CheckInvoiceToPrufung')[0]).toBe('Flow_CheckInvoiceToPruefung'); + expect(doc.suggest('flow_checkInvoiceToPrufung')[0]).toBe('flow_checkInvoiceToPruefung'); // a short id is no typo of everything - expect(doc.suggest('Done')).toEqual(['Event_Done', 'Flow_PruefungToDone']); + expect(doc.suggest('Done')).toEqual(['endEvent_done', 'flow_pruefungToDone']); expect(doc.suggest('Xyz')).toEqual([]); }); it('inside a batch: the ids it created come first and are listed in the hint', async () => { const failure = await notFound(await invoice(), [ - { op: 'add', kind: 'userTask', name: 'Vollständigkeit prüfen', after: 'Activity_CheckInvoice' }, - { op: 'add', kind: 'serviceTask', name: 'Buchen', after: 'Activity_VollstandigkeitPrufen' }, + { op: 'add', kind: 'userTask', name: 'Vollständigkeit prüfen', after: 'userTask_checkInvoice' }, + { op: 'add', kind: 'serviceTask', name: 'Buchen', after: 'userTask_vollstandigkeitPrufen' }, ]); expect(failure.code).toBe('E_NOT_FOUND'); expect(failure.details.op).toBe(1); - expect(failure.details.candidates?.[0]).toBe('Activity_VollstaendigkeitPruefen'); - expect(failure.message).toBe('No element with id "Activity_VollstandigkeitPrufen" (did you mean: Activity_VollstaendigkeitPruefen?)'); - expect(failure.details.hint).toMatch(/^Created earlier in this batch: Activity_VollstaendigkeitPruefen, Flow_VollstaendigkeitPruefenToPruefung; name them with "as": "\$name" to refer to them\./); + expect(failure.details.candidates?.[0]).toBe('userTask_vollstaendigkeitPruefen'); + expect(failure.message).toBe('No element with id "userTask_vollstandigkeitPrufen" (did you mean: userTask_vollstaendigkeitPruefen?)'); + expect(failure.details.hint).toMatch(/^Created earlier in this batch: userTask_vollstaendigkeitPruefen, flow_vollstaendigkeitPruefenToPruefung; name them with "as": "\$name" to refer to them\./); }); it('a flow an op of the batch renamed: the message says to what', async () => { const failure = await notFound(await invoice(), [ - { op: 'add', kind: 'task', name: 'Archive', after: 'Activity_Pruefung' }, - { op: 'set', id: 'Flow_PruefungToDone', values: { name: 'ok' } }, + { op: 'add', kind: 'task', name: 'Archive', after: 'userTask_pruefung' }, + { op: 'set', id: 'flow_pruefungToDone', values: { name: 'ok' } }, ]); - expect(failure.message).toBe('No element with id "Flow_PruefungToDone": an op before this one renamed it to Flow_PruefungToArchive (its id named its old ends)'); - expect(failure.details.candidates?.[0]).toBe('Flow_PruefungToArchive'); + expect(failure.message).toBe('No element with id "flow_pruefungToDone": an op before this one renamed it to flow_pruefungToArchive (its id named its old ends)'); + expect(failure.details.candidates?.[0]).toBe('flow_pruefungToArchive'); }); it('an alias written without $ is pointed out', async () => { const failure = await notFound(await invoice(), [ - { op: 'add', kind: 'task', name: 'Archive', after: 'Activity_Pruefung', as: '$archive' }, + { op: 'add', kind: 'task', name: 'Archive', after: 'userTask_pruefung', as: '$archive' }, { op: 'add', kind: 'end', name: 'Archived', after: 'archive' }, ]); expect(failure.details.hint).toMatch(/^In this batch \$archive is an alias: write "\$archive"\./); @@ -81,12 +81,12 @@ describe('E_NOT_FOUND candidates', () => { const doc = await Doc.fromXml(await invoice()); let err: BpmnError | undefined; try { - runOps(doc, [{ op: 'remove', ids: ['Activity_ChekInvoice'] }]); - doc.require('Activity_Chek'); + runOps(doc, [{ op: 'remove', ids: ['userTask_chekInvoice'] }]); + doc.require('userTask_chek'); } catch (error) { err = error as BpmnError; } - expect(err?.details.candidates?.[0]).toBe('Activity_CheckInvoice'); + expect(err?.details.candidates?.[0]).toBe('userTask_checkInvoice'); expect(err?.details.hint).toBe('Run `bpmn show ` or `bpmn find ` to list ids.'); }); }); diff --git a/test/domain/editing/c7-ext-structure.test.ts b/test/domain/editing/c7-ext-structure.test.ts index bad77030..1a282147 100644 --- a/test/domain/editing/c7-ext-structure.test.ts +++ b/test/domain/editing/c7-ext-structure.test.ts @@ -692,9 +692,9 @@ describe('boundary events on compensation handlers, duplicate flows (3)', () => expect(first.warnings.map((warning) => warning.code)).not.toContain('W_DUPLICATE_FLOW'); const second = new Connections(new EditSession(doc)).connect({ op: 'connect', source: 'Start', target: 'End' }); const dup = second.warnings.find((warning) => warning.code === 'W_DUPLICATE_FLOW'); - // the file numbers its flows F1, F2: new ones keep the prefix and name their ends; the second one takes a suffix - expect(dup?.message).toMatch(/Start -> End is already connected by the sequenceFlow F_StartToEnd\b/); - expect(second.created.map((created) => created.id)).toEqual(['F_StartToEnd_2']); + // new flows name their ends; the second one takes a suffix + expect(dup?.message).toMatch(/Start -> End is already connected by the sequenceFlow flow_startToEnd\b/); + expect(second.created.map((created) => created.id)).toEqual(['flow_startToEnd2']); expect(second.warnings.map((warning) => warning.code)).toContain('W_ID_SUFFIXED'); expect(dup?.hint).toMatch(/--if-absent/); const skipped = new Connections(new EditSession(doc)).connect({ op: 'connect', source: 'Start', target: 'End', ifAbsent: true }); diff --git a/test/domain/editing/c7-semantic.test.ts b/test/domain/editing/c7-semantic.test.ts index 2c70c550..7ba2905f 100644 --- a/test/domain/editing/c7-semantic.test.ts +++ b/test/domain/editing/c7-semantic.test.ts @@ -58,7 +58,7 @@ describe('C7 target: history time to live and platform version', () => { it('new --target camunda7 sets camunda:historyTimeToLive="180" and modeler:executionPlatformVersion', async () => { const doc = Doc.create({ target: 'camunda7', processName: 'Order' }); const xml = await doc.toXml(); - expect(xml).toMatch(/]*camunda:historyTimeToLive="180"/); + expect(xml).toMatch(/]*camunda:historyTimeToLive="180"/); expect(xml).toContain('modeler:executionPlatform="Camunda Platform"'); expect(xml).toContain('modeler:executionPlatformVersion="7.24.0"'); expect(doc.platform()).toBe('c7'); @@ -465,12 +465,12 @@ describe('receiveTask / sendTask take message=', () => { const doc = await c7Doc(); runOps(doc, [{ op: 'add', kind: 'receiveTask', name: 'Wait payment', id: 'Activity_Wait', after: 'Activity_Review' }]); const cs = set(doc, 'Activity_Wait', { message: 'PaymentReceived' }); - expect(cs.created).toContainEqual(expect.objectContaining({ id: 'Message_PaymentReceived', kind: 'message' })); - expect(await doc.toXml()).toContain(''); + expect(cs.created).toContainEqual(expect.objectContaining({ id: 'message_paymentReceived', kind: 'message' })); + expect(await doc.toXml()).toContain(''); expect(new ElementProperties(doc).read(doc.require('Activity_Wait'))).toMatchObject({ message: 'PaymentReceived' }); // an existing message by name is reused set(doc, 'Activity_Wait', { message: 'Notify' }); - expect(doc.require('Activity_Wait').get('messageRef')?.get('id')).toBe('Message_Notify'); + expect(doc.require('Activity_Wait').get('messageRef')?.get('id')).toBe('message_notify'); set(doc, 'Activity_Wait', { message: '' }); expect(doc.require('Activity_Wait').get('messageRef')).toBeUndefined(); }); @@ -479,7 +479,7 @@ describe('receiveTask / sendTask take message=', () => { const doc = await c7Doc(); const cs = runOps(doc, [{ op: 'add', kind: 'receiveTask', name: 'Wait', id: 'Activity_Wait', after: 'Activity_Review', message: 'Paid' }]); expect(codes(cs)).not.toContain('W_OPTION_IGNORED'); - expect(cs.created).toContainEqual(expect.objectContaining({ id: 'Message_Paid', kind: 'message' })); + expect(cs.created).toContainEqual(expect.objectContaining({ id: 'message_paid', kind: 'message' })); expect(doc.require('Activity_Wait').get('messageRef')?.get('name')).toBe('Paid'); runOps(doc, [{ op: 'add', kind: 'sendTask', name: 'Send', id: 'Activity_Send', after: 'Activity_Wait', set: { message: 'Sent' } }]); expect(doc.require('Activity_Send').get('messageRef')?.get('name')).toBe('Sent'); diff --git a/test/domain/editing/c8-ops.test.ts b/test/domain/editing/c8-ops.test.ts index 61c1c07d..b2528380 100644 --- a/test/domain/editing/c8-ops.test.ts +++ b/test/domain/editing/c8-ops.test.ts @@ -152,8 +152,8 @@ describe('the ops example', () => { doc, [ { op: 'add', kind: 'start', name: 'Order received' }, - { op: 'add', kind: 'userTask', name: 'Check invoice', after: 'Event_OrderReceived' }, - { op: 'add', kind: 'end', name: 'Invoice handled', after: 'Activity_CheckInvoice' }, + { op: 'add', kind: 'userTask', name: 'Check invoice', after: 'startEvent_orderReceived' }, + { op: 'add', kind: 'end', name: 'Invoice handled', after: 'userTask_checkInvoice' }, ...opsExample().ops, ], { dryRun: true }, diff --git a/test/domain/editing/groups.test.ts b/test/domain/editing/groups.test.ts index 716063e4..b5010d70 100644 --- a/test/domain/editing/groups.test.ts +++ b/test/domain/editing/groups.test.ts @@ -144,7 +144,7 @@ describe('set and remove on groups', () => { { op: 'add', kind: 'task', name: 'Charge', in: '$sub', as: '$charge' }, { op: 'add', kind: 'group', name: 'Inner', members: ['$charge'] }, ]); - expect(xml).toMatch(/(?:(?!<\/bpmn:subProcess>)[\s\S])*(?:(?!<\/bpmn:subProcess>)[\s\S])*_di` DI (`bpmn`: the engine's `BPMNShape_`): start -> Check -> Ok? -> Book (default) -> Done. */ async function drawnDefault(style: 'suffix' | 'bpmn' = 'suffix'): Promise { const ops: EditCommand[] = [ { op: 'add', kind: 'startEvent', name: 'Start' }, - { op: 'add', kind: 'task', name: 'Check', after: 'Event_Start' }, - { op: 'add', kind: 'exclusiveGateway', name: 'Ok?', after: 'Activity_Check' }, - { op: 'add', kind: 'task', name: 'Book', after: 'Gateway_Ok', default: true }, - { op: 'add', kind: 'endEvent', name: 'Done', after: 'Activity_Book' }, + { op: 'add', kind: 'task', name: 'Check', after: 'startEvent_start' }, + { op: 'add', kind: 'exclusiveGateway', name: 'Ok?', after: 'task_check' }, + { op: 'add', kind: 'task', name: 'Book', after: 'gateway_ok', default: true }, + { op: 'add', kind: 'endEvent', name: 'Done', after: 'task_book' }, ]; const xml = (await applyToXml((await newXml({ processName: 'P' })).xml, ops)).xml; return style === 'bpmn' ? xml : xml.replace(/BPMN(Shape|Edge)_([A-Za-z0-9_]+)/g, '$2_di'); @@ -37,86 +35,86 @@ async function drawnDefault(style: 'suffix' | 'bpmn' = 'suffix'): PromiseFlow_StartToCheck - Flow_StartToCheckFlow_CheckToOk - Flow_CheckToOkFlow_OkToBook - Flow_OkToBookFlow_BookToDone - Flow_BookToDone - - - - `); + flow_startToCheck + flow_startToCheckflow_checkToOk + flow_checkToOkflow_okToBook + flow_okToBookflow_bookToDone + flow_bookToDone + + + + `); /** A split into A and B, joined before Done (remove --bridge-all of the join). */ const JOINED = definitionsXml(` - Flow_StartToOk - Flow_StartToOkFlow_OkToAFlow_OkToB - Flow_OkToAFlow_AToJoin - Flow_OkToBFlow_BToJoin - Flow_AToJoinFlow_BToJoinFlow_JoinToDone - Flow_JoinToDone - - - - - - `); + flow_startToOk + flow_startToOkflow_okToAflow_okToB + flow_okToAflow_aToJoin + flow_okToBflow_bToJoin + flow_aToJoinflow_bToJoinflow_joinToDone + flow_joinToDone + + + + + + `); /** Start -> A -> B -> C -> Done, for a move of two nodes. */ const LINE = definitionsXml(` - Flow_StartToA - Flow_StartToAFlow_AToB - Flow_AToBFlow_BToC - Flow_BToCFlow_CToDone - Flow_CToDone - - - - `); + flow_startToA + flow_startToAflow_aToB + flow_aToBflow_bToC + flow_bToCflow_cToDone + flow_cToDone + + + + `); /** LINE with A as an exclusive gateway: a move after it appends. */ -const FORKED = LINE.replace(//, ''); +const FORKED = LINE.replace(//, ''); /** LINE with a second flow out of B (B -> Done): B is not bridged, so a move has no out-flow of B to put back. */ -const TWO_OUT = LINE.replace('Flow_BToC', 'Flow_BToCFlow_BToDone') - .replace('Flow_CToDone', 'Flow_CToDoneFlow_BToDone') - .replace('\n flow_bToC', 'flow_bToCflow_bToDone') + .replace('flow_cToDone', 'flow_cToDoneflow_bToDone') + .replace('\n C has a label and a condition: B is not bridged. */ const JOIN_B = definitionsXml(` - Flow_StartToB - Flow_AToB - Flow_StartToBFlow_AToBFlow_BToC - Flow_BToCFlow_CToDone - Flow_CToDone - - - \${approved} - `); + flow_startToB + flow_aToB + flow_startToBflow_aToBflow_bToC + flow_bToCflow_cToDone + flow_cToDone + + + \${approved} + `); /** Start -> X -> Done, and apart from it A -> B (A has no flow in, so it is not bridged) with a label and a condition. */ const APART = definitionsXml(` - Flow_StartToX - Flow_StartToXFlow_XToDone - Flow_XToDone - Flow_AToB - Flow_AToB - - - \${approved}`); + flow_startToX + flow_startToXflow_xToDone + flow_xToDone + flow_aToB + flow_aToB + + + \${approved}`); /** A split into A and B, both ending in Done: Done has two flows in. */ const SHARED_END = definitionsXml(` - Flow_StartToOk - Flow_StartToOkFlow_OkToAFlow_OkToB - Flow_OkToAFlow_AToDone - Flow_OkToBFlow_BToDone - Flow_AToDoneFlow_BToDone - - - - - `); + flow_startToOk + flow_startToOkflow_okToAflow_okToB + flow_okToAflow_aToDone + flow_okToBflow_bToDone + flow_aToDoneflow_bToDone + + + + + `); /** A Camunda Modeler file: hashed flow ids say nothing about their ends. */ const MODELER = definitionsXml(` @@ -144,39 +142,39 @@ const flowIds = (changes: ChangeSet['created']): string[] => changes.filter((cha describe('keepFlowIds: a flow the file had keeps its id and the change names it', () => { it('a splice keeps the flow id, its DI edge and the default reference; one W_FLOW_ID_STALE with the rename command, no renamed', async () => { const xml = await drawnDefault(); - const result = await applyToXml(xml, [{ op: 'add', kind: 'task', name: 'Review', flow: 'Flow_OkToBook' }], { keepFlowIds: true }); - expect(result.result.created.map((change) => change.id)).toEqual(['Activity_Review', 'Flow_ReviewToBook']); - expect(result.result.changed.map((change) => `${change.id}: ${change.detail}`)).toContain('Flow_OkToBook: Gateway_Ok -> Activity_Review (was -> Activity_Book)'); + const result = await applyToXml(xml, [{ op: 'add', kind: 'task', name: 'Review', flow: 'flow_okToBook' }], { keepFlowIds: true }); + expect(result.result.created.map((change) => change.id)).toEqual(['task_review', 'flow_reviewToBook']); + expect(result.result.changed.map((change) => `${change.id}: ${change.detail}`)).toContain('flow_okToBook: gateway_ok -> task_review (was -> task_book)'); expect(result.result.changed.some((change) => change.detail?.includes('renamed from'))).toBe(false); expect(result.result.renamed).toBeUndefined(); - expect(result.xml).toContain(''); - expect(result.xml).toContain(''); - expect(result.xml).toContain(''); + expect(result.xml).toContain(''); + expect(result.xml).toContain(''); + expect(result.xml).toContain(''); expect(stale(result.result.warnings.added)).toEqual([ { code: 'W_FLOW_ID_STALE', - message: 'Flow Flow_OkToBook kept its id, which names its old ends Gateway_Ok -> Activity_Book; it now runs Gateway_Ok -> Activity_Review', - element: 'Flow_OkToBook', - related: ['Gateway_Ok', 'Activity_Review'], - hint: 'Code and tests that use the id keep working; to rename it together with them: `bpmn set Flow_OkToBook id=Flow_OkToReview`.', + message: 'Flow flow_okToBook kept its id, which names its old ends gateway_ok -> task_book; it now runs gateway_ok -> task_review', + element: 'flow_okToBook', + related: ['gateway_ok', 'task_review'], + hint: 'Code and tests that use the id keep working; to rename it together with them: `bpmn set flow_okToBook id=flow_okToReview`.', }, ]); // the hint's command renames it deliberately; nothing is stale any more - const renamed = await applyToXml(result.xml, [{ op: 'set', id: 'Flow_OkToBook', values: { id: 'Flow_OkToReview' } }], { keepFlowIds: true }); - expect(renamed.xml).toContain(''); + const renamed = await applyToXml(result.xml, [{ op: 'set', id: 'flow_okToBook', values: { id: 'flow_okToReview' } }], { keepFlowIds: true }); + expect(renamed.xml).toContain(''); expect(stale(renamed.result.warnings.added)).toEqual([]); }); it('every op that changes the ends of a flow: add --after / --before / --flow / --after --before, split, remove, set source= / target=', async () => { const cases: Array<[EditCommand, string]> = [ - [{ op: 'add', kind: 'task', name: 'Review', after: 'Activity_Check' }, 'Flow_CheckToOk Activity_Check->Activity_Review => Flow_CheckToReview'], - [{ op: 'add', kind: 'task', name: 'Prepare', before: 'Activity_Check' }, 'Flow_StartToCheck Event_Start->Activity_Prepare => Flow_StartToPrepare'], - [{ op: 'add', kind: 'task', name: 'Review', flow: 'Flow_OkToBook' }, 'Flow_OkToBook Gateway_Ok->Activity_Review => Flow_OkToReview'], - [{ op: 'add', kind: 'task', name: 'Review', after: 'Activity_Check', before: 'Gateway_Ok' }, 'Flow_CheckToOk Activity_Check->Activity_Review => Flow_CheckToReview'], - [{ op: 'split', after: 'Activity_Check', name: 'Valid?', branches: [{ nodes: [{ kind: 'task', name: 'Fix' }] }, { nodes: [] }] }, 'Flow_CheckToOk Activity_Check->Gateway_Valid => Flow_CheckToValid'], - [{ op: 'remove', ids: ['Activity_Book'] }, 'Flow_OkToBook Gateway_Ok->Event_Done => Flow_OkToDone'], - [{ op: 'set', id: 'Flow_BookToDone', values: { target: 'Activity_Check' } }, 'Flow_BookToDone Activity_Book->Activity_Check => Flow_BookToCheck'], - [{ op: 'set', id: 'Flow_BookToDone', values: { source: 'Activity_Check' } }, 'Flow_BookToDone Activity_Check->Event_Done => Flow_CheckToDone'], + [{ op: 'add', kind: 'task', name: 'Review', after: 'task_check' }, 'flow_checkToOk task_check->task_review => flow_checkToReview'], + [{ op: 'add', kind: 'task', name: 'Prepare', before: 'task_check' }, 'flow_startToCheck startEvent_start->task_prepare => flow_startToPrepare'], + [{ op: 'add', kind: 'task', name: 'Review', flow: 'flow_okToBook' }, 'flow_okToBook gateway_ok->task_review => flow_okToReview'], + [{ op: 'add', kind: 'task', name: 'Review', after: 'task_check', before: 'gateway_ok' }, 'flow_checkToOk task_check->task_review => flow_checkToReview'], + [{ op: 'split', after: 'task_check', name: 'Valid?', branches: [{ nodes: [{ kind: 'task', name: 'Fix' }] }, { nodes: [] }] }, 'flow_checkToOk task_check->gateway_valid => flow_checkToValid'], + [{ op: 'remove', ids: ['task_book'] }, 'flow_okToBook gateway_ok->endEvent_done => flow_okToDone'], + [{ op: 'set', id: 'flow_bookToDone', values: { target: 'task_check' } }, 'flow_bookToDone task_book->task_check => flow_bookToCheck'], + [{ op: 'set', id: 'flow_bookToDone', values: { source: 'task_check' } }, 'flow_bookToDone task_check->endEvent_done => flow_checkToDone'], ]; for (const [op, line] of cases) { const result = await kept([op]); @@ -189,173 +187,173 @@ describe('keepFlowIds: a flow the file had keeps its id and the change names it' }); it('a move keeps the bridge at its old place, the splice at its new one and the node\'s out-flow, put back there; remove --bridge-all one per predecessor', async () => { - const moved = await kept([{ op: 'move', ids: ['Activity_Book'], before: 'Activity_Check' }]); + const moved = await kept([{ op: 'move', ids: ['task_book'], before: 'task_check' }]); expect(staleLines(moved.warnings).sort()).toEqual([ - 'Flow_BookToDone Activity_Book->Activity_Check => Flow_BookToCheck', - 'Flow_OkToBook Gateway_Ok->Event_Done => Flow_OkToDone', - 'Flow_StartToCheck Event_Start->Activity_Book => Flow_StartToBook', + 'flow_bookToDone task_book->task_check => flow_bookToCheck', + 'flow_okToBook gateway_ok->endEvent_done => flow_okToDone', + 'flow_startToCheck startEvent_start->task_book => flow_startToBook', ]); // Book's out-flow is its out-flow at the new place: changed, neither removed nor created - expect(flowBetween(moved.doc, 'Activity_Book', 'Activity_Check')).toBe('Flow_BookToDone'); + expect(flowBetween(moved.doc, 'task_book', 'task_check')).toBe('flow_bookToDone'); expect([flowIds(moved.cs.created), flowIds(moved.cs.removed)]).toEqual([[], []]); - expect(moved.cs.changed.map((change) => `${change.id}: ${change.detail}`)).toContain('Flow_BookToDone: Activity_Book -> Activity_Check (was -> Event_Done)'); - const bridged = await kept([{ op: 'remove', ids: ['Gateway_Join'], bridgeAll: true }], JOINED); - expect(staleLines(bridged.warnings)).toEqual(['Flow_AToJoin Activity_A->Event_Done => Flow_AToDone', 'Flow_BToJoin Activity_B->Event_Done => Flow_BToDone']); + expect(moved.cs.changed.map((change) => `${change.id}: ${change.detail}`)).toContain('flow_bookToDone: task_book -> task_check (was -> endEvent_done)'); + const bridged = await kept([{ op: 'remove', ids: ['gateway_join'], bridgeAll: true }], JOINED); + expect(staleLines(bridged.warnings)).toEqual(['flow_aToJoin task_a->endEvent_done => flow_aToDone', 'flow_bToJoin task_b->endEvent_done => flow_bToDone']); }); it('nodes moved together keep the flow between them, also when they are appended; later ops of the batch use every id', async () => { const result = await applyToXml(LINE, [ - { op: 'move', ids: ['Activity_B', 'Activity_C'], after: 'Event_Start' }, - { op: 'set', id: 'Flow_BToC', values: { name: 'go' } }, + { op: 'move', ids: ['task_b', 'task_c'], after: 'startEvent_start' }, + { op: 'set', id: 'flow_bToC', values: { name: 'go' } }, ], { keepFlowIds: true, layout: false }); expect([flowIds(result.result.created), flowIds(result.result.removed)]).toEqual([[], []]); - expect(result.xml).toContain(''); + expect(result.xml).toContain(''); // B -> C runs between its old ends again: no warning for it expect(staleLines(result.result.warnings.added).sort()).toEqual([ - 'Flow_AToB Activity_A->Event_Done => Flow_AToDone', - 'Flow_CToDone Activity_C->Activity_A => Flow_CToA', - 'Flow_StartToA Event_Start->Activity_B => Flow_StartToB', + 'flow_aToB task_a->endEvent_done => flow_aToDone', + 'flow_cToDone task_c->task_a => flow_cToA', + 'flow_startToA startEvent_start->task_b => flow_startToB', ]); // after a gateway B is appended (a new flow into it); C is appended to B through B's old out-flow, C's has no place - const appended = await kept([{ op: 'move', ids: ['Activity_B', 'Activity_C'], after: 'Activity_A' }], FORKED); - expect(flowBetween(appended.doc, 'Activity_B', 'Activity_C')).toBe('Flow_BToC'); - expect([flowIds(appended.cs.created), flowIds(appended.cs.removed)]).toEqual([['Flow_AToB_2'], ['Flow_CToDone']]); - expect(staleLines(appended.warnings)).toEqual(['Flow_AToB Activity_A->Event_Done => Flow_AToDone']); + const appended = await kept([{ op: 'move', ids: ['task_b', 'task_c'], after: 'task_a' }], FORKED); + expect(flowBetween(appended.doc, 'task_b', 'task_c')).toBe('flow_bToC'); + expect([flowIds(appended.cs.created), flowIds(appended.cs.removed)]).toEqual([['flow_aToB2'], ['flow_cToDone']]); + expect(staleLines(appended.warnings)).toEqual(['flow_aToB task_a->endEvent_done => flow_aToDone']); }); it('nodes moved together keep the flow between them when the second is not bridged: an end event, a join', async () => { // C is put back where it was, then Done: Done has no flow out, so C -> Done is taken out and put back by the append - const tail = await applyToXml(LINE, [{ op: 'move', ids: ['Activity_C', 'Event_Done'], after: 'Activity_B' }], { keepFlowIds: true, layout: false }); + const tail = await applyToXml(LINE, [{ op: 'move', ids: ['task_c', 'endEvent_done'], after: 'task_b' }], { keepFlowIds: true, layout: false }); expect([flowIds(tail.result.created), flowIds(tail.result.removed)]).toEqual([[], []]); expect(tail.result.warnings.added.map((warning) => warning.code)).toEqual([]); - expect(tail.xml).toContain(''); + expect(tail.xml).toContain(''); // Done has two flows in: B -> Done is removed like every other flow of a node that is not bridged, A -> Done stays A's - const shared = await kept([{ op: 'move', ids: ['Activity_A', 'Event_Done'], after: 'Activity_B' }], SHARED_END); - expect(flowBetween(shared.doc, 'Activity_A', 'Event_Done')).toBe('Flow_AToDone'); - expect([flowIds(shared.cs.created), flowIds(shared.cs.removed)]).toEqual([[], ['Flow_OkToA']]); + const shared = await kept([{ op: 'move', ids: ['task_a', 'endEvent_done'], after: 'task_b' }], SHARED_END); + expect(flowBetween(shared.doc, 'task_a', 'endEvent_done')).toBe('flow_aToDone'); + expect([flowIds(shared.cs.created), flowIds(shared.cs.removed)]).toEqual([[], ['flow_okToA']]); expect(shared.warnings.map((warning) => warning.code)).not.toContain('W_ID_SUFFIXED'); // a gateway first: the next node is appended to it (a new branch), so its out-flow waits for that append - const gateway = await kept([{ op: 'move', ids: ['Gateway_Ok', 'Activity_Book'], after: 'Event_Start' }]); - expect(flowBetween(gateway.doc, 'Gateway_Ok', 'Activity_Book')).toBe('Flow_OkToBook'); - expect([flowIds(gateway.cs.created), flowIds(gateway.cs.removed)]).toEqual([['Flow_OkToCheck'], ['Flow_BookToDone']]); + const gateway = await kept([{ op: 'move', ids: ['gateway_ok', 'task_book'], after: 'startEvent_start' }]); + expect(flowBetween(gateway.doc, 'gateway_ok', 'task_book')).toBe('flow_okToBook'); + expect([flowIds(gateway.cs.created), flowIds(gateway.cs.removed)]).toEqual([['flow_okToCheck'], ['flow_bookToDone']]); // the flow keeps what it had: the name the prepend gave A -> Join, before the join moves after A - const named = await kept([{ op: 'move', ids: ['Activity_A', 'Gateway_Join'], before: 'Gateway_Join', flowName: 'done' }], JOINED); - expect(flowBetween(named.doc, 'Activity_A', 'Gateway_Join')).toBe('Flow_AToJoin'); - expect(named.doc.get('Flow_AToJoin')!.get('name')).toBe('done'); + const named = await kept([{ op: 'move', ids: ['task_a', 'gateway_join'], before: 'gateway_join', flowName: 'done' }], JOINED); + expect(flowBetween(named.doc, 'task_a', 'gateway_join')).toBe('flow_aToJoin'); + expect(named.doc.get('flow_aToJoin')!.get('name')).toBe('done'); expect(flowIds(named.cs.created)).toEqual([]); }); it('a moved node that is not bridged puts its out-flow back without the label and condition no bridge took, and says so; between its old ends it keeps them', async () => { - const result = await applyToXml(JOIN_B, [{ op: 'move', ids: ['Activity_B'], after: 'Activity_C' }], { keepFlowIds: true, layout: false }); - expect(result.xml).toContain(''); + const result = await applyToXml(JOIN_B, [{ op: 'move', ids: ['task_b'], after: 'task_c' }], { keepFlowIds: true, layout: false }); + expect(result.xml).toContain(''); const dropped = result.result.warnings.added.filter((warning) => warning.code === 'W_LABEL_DROPPED' || warning.code === 'W_CONDITION_DROPPED'); expect(dropped.map((warning) => `${warning.code} ${warning.element}: ${warning.message}`)).toEqual([ - 'W_LABEL_DROPPED Flow_BToC: Flow label "approved" of Flow_BToC was dropped while moving Activity_B: Activity_B was not bridged, so nothing at its old place took it, and Flow_BToC was put back at its new place without it', - 'W_CONDITION_DROPPED Flow_BToC: Condition of Flow_BToC was dropped while moving Activity_B: Activity_B was not bridged, so nothing at its old place took it, and Flow_BToC was put back at its new place without it', + 'W_LABEL_DROPPED flow_bToC: Flow label "approved" of flow_bToC was dropped while moving task_b: task_b was not bridged, so nothing at its old place took it, and flow_bToC was put back at its new place without it', + 'W_CONDITION_DROPPED flow_bToC: Condition of flow_bToC was dropped while moving task_b: task_b was not bridged, so nothing at its old place took it, and flow_bToC was put back at its new place without it', ]); - // B before C: Flow_BToC is put back between its old ends, with its label and condition - const same = await applyToXml(JOIN_B, [{ op: 'move', ids: ['Activity_B'], before: 'Activity_C' }], { keepFlowIds: true, layout: false }); - expect(same.xml).toMatch(/\s*\$\{approved\}<\/bpmn:conditionExpression>/); + // B before C: flow_bToC is put back between its old ends, with its label and condition + const same = await applyToXml(JOIN_B, [{ op: 'move', ids: ['task_b'], before: 'task_c' }], { keepFlowIds: true, layout: false }); + expect(same.xml).toMatch(/\s*\$\{approved\}<\/bpmn:conditionExpression>/); expect(same.result.warnings.added.map((warning) => warning.code)).not.toContain('W_LABEL_DROPPED'); // a bridged node: the bridge takes both (A -> C), nothing is dropped - const bridged = await applyToXml(LINE.replace('id="Flow_BToC" sourceRef', 'id="Flow_BToC" name="approved" sourceRef'), [{ op: 'move', ids: ['Activity_B'], after: 'Activity_C' }], { keepFlowIds: true, layout: false }); - expect(bridged.xml).toContain(''); + const bridged = await applyToXml(LINE.replace('id="flow_bToC" sourceRef', 'id="flow_bToC" name="approved" sourceRef'), [{ op: 'move', ids: ['task_b'], after: 'task_c' }], { keepFlowIds: true, layout: false }); + expect(bridged.xml).toContain(''); expect(bridged.result.warnings.added.map((warning) => warning.code)).not.toContain('W_LABEL_DROPPED'); // by default the flow is removed and the change names it with its label, as before - const renamed = await applyToXml(JOIN_B, [{ op: 'move', ids: ['Activity_B'], after: 'Activity_C' }], { layout: false }); - expect(renamed.result.removed).toContainEqual({ id: 'Flow_BToC', kind: 'sequenceFlow', name: 'approved', detail: 'Activity_B -> Activity_C' }); + const renamed = await applyToXml(JOIN_B, [{ op: 'move', ids: ['task_b'], after: 'task_c' }], { layout: false }); + expect(renamed.result.removed).toContainEqual({ id: 'flow_bToC', kind: 'sequenceFlow', name: 'approved', detail: 'task_b -> task_c' }); expect(renamed.result.warnings.added.map((warning) => warning.code)).not.toContain('W_LABEL_DROPPED'); }); it('a chain whose first node is not bridged: the flow to the next node, put back elsewhere and then between its old ends again, gets its label back', async () => { // A's splice puts A -> B back as A -> Done, B's splice after A ends it at B again - const result = await applyToXml(APART, [{ op: 'move', ids: ['Activity_A', 'Activity_B'], after: 'Activity_X' }], { keepFlowIds: true, layout: false }); - expect(result.xml).toMatch(/\s*\$\{approved\}<\/bpmn:conditionExpression>/); + const result = await applyToXml(APART, [{ op: 'move', ids: ['task_a', 'task_b'], after: 'task_x' }], { keepFlowIds: true, layout: false }); + expect(result.xml).toMatch(/\s*\$\{approved\}<\/bpmn:conditionExpression>/); expect(result.result.warnings.added.map((warning) => warning.code)).toEqual(['W_FLOW_ID_STALE']); - expect(result.result.changed.filter((change) => change.id === 'Flow_AToB')).toEqual([{ id: 'Flow_AToB', kind: 'sequenceFlow', name: 'approved', detail: 'Activity_A -> Activity_B' }]); + expect(result.result.changed.filter((change) => change.id === 'flow_aToB')).toEqual([{ id: 'flow_aToB', kind: 'sequenceFlow', name: 'approved', detail: 'task_a -> task_b' }]); // its default marker comes back the same way - const marked = APART.replace('', '').replace(//, ''); - const def = await kept([{ op: 'move', ids: ['Activity_A', 'Activity_B'], after: 'Activity_X' }], marked); - expect(def.doc.get('Activity_A')!.get('default')).toBe(def.doc.get('Flow_AToB')); + const marked = APART.replace('', '').replace(//, ''); + const def = await kept([{ op: 'move', ids: ['task_a', 'task_b'], after: 'task_x' }], marked); + expect(def.doc.get('task_a')!.get('default')).toBe(def.doc.get('flow_aToB')); expect(def.warnings.map((warning) => warning.code)).toEqual(['W_FLOW_ID_STALE']); }); it('a put-back default flow never carries a condition, and a default the placement gave wins over the marker the flow had', async () => { - // B (two flows in, not bridged) before C, its old target: the prepend makes Flow_BToC the default flow, so its condition stays dropped - const result = await applyToXml(JOIN_B, [{ op: 'move', ids: ['Activity_B'], before: 'Activity_C', default: true }], { keepFlowIds: true, layout: false }); - expect(result.xml).toContain(''); - expect(result.xml).toContain(''); + // B (two flows in, not bridged) before C, its old target: the prepend makes flow_bToC the default flow, so its condition stays dropped + const result = await applyToXml(JOIN_B, [{ op: 'move', ids: ['task_b'], before: 'task_c', default: true }], { keepFlowIds: true, layout: false }); + expect(result.xml).toContain(''); + expect(result.xml).toContain(''); expect(result.result.warnings.added.filter((warning) => warning.code === 'W_CONDITION_DROPPED').map((warning) => warning.message)).toEqual([ - 'Condition of Flow_BToC was dropped while moving Activity_B: Flow_BToC is now the default flow of Activity_B', + 'Condition of flow_bToC was dropped while moving task_b: flow_bToC is now the default flow of task_b', ]); // the join's default flow comes back to Done, but the prepend made Join -> Archive the default, as asked (as by default) - const archive = JOINED.replace('', '') - .replace('\n ', '') + .replace('\n ('default')).toBe(wins.doc.get('Flow_JoinToArchive')); - expect(wins.cs.changed.filter((change) => change.id === 'Flow_JoinToDone').map((change) => change.detail)).toEqual(['Gateway_Join -> Event_Done (no longer the default flow of Gateway_Join)']); - expect((await applyToXml(archive, ops, { layout: false })).xml).toContain(''); + expect(flowBetween(wins.doc, 'gateway_join', 'endEvent_done')).toBe('flow_joinToDone'); + expect(wins.doc.get('gateway_join')!.get('default')).toBe(wins.doc.get('flow_joinToArchive')); + expect(wins.cs.changed.filter((change) => change.id === 'flow_joinToDone').map((change) => change.detail)).toEqual(['gateway_join -> endEvent_done (no longer the default flow of gateway_join)']); + expect((await applyToXml(archive, ops, { layout: false })).xml).toContain(''); }); it('none for a hashed id, an id that did not name its ends, ends a later op restored, or a flow a later op removed', async () => { const hashed = await kept([{ op: 'add', kind: 'task', name: 'Pack', after: 'Activity_0k3x9qa' }], MODELER); expect(stale(hashed.warnings)).toEqual([]); - expect(flowBetween(hashed.doc, 'Activity_0k3x9qa', 'Activity_Pack')).toBe('Flow_1q2w3e4'); + expect(flowBetween(hashed.doc, 'Activity_0k3x9qa', 'task_pack')).toBe('Flow_1q2w3e4'); const own = await kept([ - { op: 'connect', source: 'Gateway_Ok', target: 'Event_Done', id: 'Flow_Shortcut', name: 'skip' }, - { op: 'add', kind: 'task', name: 'Log', flow: 'Flow_Shortcut' }, + { op: 'connect', source: 'gateway_ok', target: 'endEvent_done', id: 'flow_shortcut', name: 'skip' }, + { op: 'add', kind: 'task', name: 'Log', flow: 'flow_shortcut' }, ]); expect(stale(own.warnings)).toEqual([]); // add, then remove the new node: the bridge puts the flow back between its old ends const restored = await kept([ - { op: 'add', kind: 'task', name: 'Review', after: 'Activity_Check' }, - { op: 'remove', ids: ['Activity_Review'] }, + { op: 'add', kind: 'task', name: 'Review', after: 'task_check' }, + { op: 'remove', ids: ['task_review'] }, ]); expect(stale(restored.warnings)).toEqual([]); - expect(flowBetween(restored.doc, 'Activity_Check', 'Gateway_Ok')).toBe('Flow_CheckToOk'); - // Flow_CheckToOk goes stale, then goes with Activity_Check; the bridge of that remove makes Flow_StartToCheck stale + expect(flowBetween(restored.doc, 'task_check', 'gateway_ok')).toBe('flow_checkToOk'); + // flow_checkToOk goes stale, then goes with task_check; the bridge of that remove makes flow_startToCheck stale const gone = await kept([ - { op: 'add', kind: 'task', name: 'Review', after: 'Activity_Check' }, - { op: 'remove', ids: ['Activity_Check'] }, + { op: 'add', kind: 'task', name: 'Review', after: 'task_check' }, + { op: 'remove', ids: ['task_check'] }, ]); - expect(staleLines(gone.warnings)).toEqual(['Flow_StartToCheck Event_Start->Activity_Review => Flow_StartToReview']); + expect(staleLines(gone.warnings)).toEqual(['flow_startToCheck startEvent_start->task_review => flow_startToReview']); }); it('a flow created earlier in the same change follows its ends; one warning per flow, against its final ends', async () => { const result = await applyToXml(await drawnDefault(), [ - { op: 'add', kind: 'task', name: 'Review', after: 'Activity_Check' }, - { op: 'add', kind: 'task', name: 'Approve', after: 'Activity_Review' }, - { op: 'add', kind: 'task', name: 'Archive', before: 'Activity_Review' }, + { op: 'add', kind: 'task', name: 'Review', after: 'task_check' }, + { op: 'add', kind: 'task', name: 'Approve', after: 'task_review' }, + { op: 'add', kind: 'task', name: 'Archive', before: 'task_review' }, ], { keepFlowIds: true }); - // Flow_ReviewToOk (made by the first op) was spliced by the second: renamed after its new ends, listed under its final id - expect(result.result.created.map((change) => change.id)).toEqual(['Activity_Review', 'Flow_ReviewToApprove', 'Activity_Approve', 'Flow_ApproveToOk', 'Activity_Archive', 'Flow_ArchiveToReview']); + // flow_reviewToOk (made by the first op) was spliced by the second: renamed after its new ends, listed under its final id + expect(result.result.created.map((change) => change.id)).toEqual(['task_review', 'flow_reviewToApprove', 'task_approve', 'flow_approveToOk', 'task_archive', 'flow_archiveToReview']); expect(result.result.renamed).toBeUndefined(); - // Flow_CheckToOk was spliced twice (Check -> Review, then Check -> Archive): one warning, its first ends and its last - expect(stale(result.result.warnings.added).map((warning) => warning.message)).toEqual(['Flow Flow_CheckToOk kept its id, which names its old ends Activity_Check -> Gateway_Ok; it now runs Activity_Check -> Activity_Archive']); + // flow_checkToOk was spliced twice (Check -> Review, then Check -> Archive): one warning, its first ends and its last + expect(stale(result.result.warnings.added).map((warning) => warning.message)).toEqual(['Flow flow_checkToOk kept its id, which names its old ends task_check -> gateway_ok; it now runs task_check -> task_archive']); }); it('a flow the same change renames with `set id=` takes that id; a later splice names the ends that id named', async () => { const chosen = await kept([ - { op: 'add', kind: 'task', name: 'Review', after: 'Activity_Check' }, - { op: 'set', id: 'Flow_CheckToOk', values: { id: 'Flow_ToReview' } }, + { op: 'add', kind: 'task', name: 'Review', after: 'task_check' }, + { op: 'set', id: 'flow_checkToOk', values: { id: 'flow_toReview' } }, ]); expect(stale(chosen.warnings)).toEqual([]); const spliced = await kept([ - { op: 'add', kind: 'task', name: 'Review', after: 'Activity_Check' }, - { op: 'set', id: 'Flow_CheckToOk', values: { id: 'Flow_CheckToReview' } }, - { op: 'add', kind: 'task', name: 'Archive', after: 'Activity_Check' }, + { op: 'add', kind: 'task', name: 'Review', after: 'task_check' }, + { op: 'set', id: 'flow_checkToOk', values: { id: 'flow_checkToReview' } }, + { op: 'add', kind: 'task', name: 'Archive', after: 'task_check' }, ]); - expect(stale(spliced.warnings).map((warning) => warning.message)).toEqual(['Flow Flow_CheckToReview kept its id, which names its old ends Activity_Check -> Activity_Review; it now runs Activity_Check -> Activity_Archive']); + expect(stale(spliced.warnings).map((warning) => warning.message)).toEqual(['Flow flow_checkToReview kept its id, which names its old ends task_check -> task_review; it now runs task_check -> task_archive']); }); it('the default renames as before and never warns', async () => { - const result = await applyToXml(await drawnDefault(), [{ op: 'add', kind: 'task', name: 'Review', after: 'Activity_Check' }]); - expect(result.result.renamed).toEqual({ Flow_CheckToOk: 'Flow_CheckToReview' }); + const result = await applyToXml(await drawnDefault(), [{ op: 'add', kind: 'task', name: 'Review', after: 'task_check' }]); + expect(result.result.renamed).toEqual({ flow_checkToOk: 'flow_checkToReview' }); expect(stale(result.result.warnings.added)).toEqual([]); - const off = await applyToXml(await drawnDefault(), [{ op: 'add', kind: 'task', name: 'Review', after: 'Activity_Check' }], { keepFlowIds: false }); - expect(off.result.renamed).toEqual({ Flow_CheckToOk: 'Flow_CheckToReview' }); + const off = await applyToXml(await drawnDefault(), [{ op: 'add', kind: 'task', name: 'Review', after: 'task_check' }], { keepFlowIds: false }); + expect(off.result.renamed).toEqual({ flow_checkToOk: 'flow_checkToReview' }); }); }); @@ -363,87 +361,87 @@ describe('keepFlowIds in the library: the option, Doc.followFlowEnds and the rep it('an explicit option wins over Doc.followFlowEnds, which is the caller\'s again afterwards (also after a refusal)', async () => { const doc = await Doc.fromXml(CHAIN); doc.followFlowEnds = false; - const renamed = await mutateDoc(doc, [{ op: 'add', kind: 'task', name: 'Review', after: 'Activity_Check' }], { keepFlowIds: false, layout: false }); - expect(renamed.changes.renames.map((result) => `${result.from} -> ${result.to}`)).toEqual(['Flow_CheckToOk -> Flow_CheckToReview']); + const renamed = await mutateDoc(doc, [{ op: 'add', kind: 'task', name: 'Review', after: 'task_check' }], { keepFlowIds: false, layout: false }); + expect(renamed.changes.renames.map((result) => `${result.from} -> ${result.to}`)).toEqual(['flow_checkToOk -> flow_checkToReview']); expect(doc.followFlowEnds).toBe(false); // undefined: the field decides const keptDoc = await Doc.fromXml(CHAIN); keptDoc.followFlowEnds = false; - const kept = await mutateDoc(keptDoc, [{ op: 'add', kind: 'task', name: 'Review', after: 'Activity_Check' }], { layout: false }); - expect(stale(kept.changes.warnings).map((warning) => warning.element)).toEqual(['Flow_CheckToOk']); + const kept = await mutateDoc(keptDoc, [{ op: 'add', kind: 'task', name: 'Review', after: 'task_check' }], { layout: false }); + expect(stale(kept.changes.warnings).map((warning) => warning.element)).toEqual(['flow_checkToOk']); const fresh = await Doc.fromXml(CHAIN); - await expect(mutateDoc(fresh, [{ op: 'add', kind: 'task', name: 'Review', after: 'Activity_Nope' }], { keepFlowIds: true, layout: false })).rejects.toMatchObject({ code: 'E_NOT_FOUND' }); + await expect(mutateDoc(fresh, [{ op: 'add', kind: 'task', name: 'Review', after: 'task_nope' }], { keepFlowIds: true, layout: false })).rejects.toMatchObject({ code: 'E_NOT_FOUND' }); expect(fresh.followFlowEnds).toBe(true); }); it('runOp on its own reports the flow it kept, like a batch', async () => { const doc = await Doc.fromXml(CHAIN); doc.followFlowEnds = false; - const cs = runOp(doc, { op: 'remove', ids: ['Activity_Book'] }); - expect(staleLines(cs.warnings)).toEqual(['Flow_OkToBook Gateway_Ok->Event_Done => Flow_OkToDone']); + const cs = runOp(doc, { op: 'remove', ids: ['task_book'] }); + expect(staleLines(cs.warnings)).toEqual(['flow_okToBook gateway_ok->endEvent_done => flow_okToDone']); }); it('a flow the op or an earlier op of the batch created follows its ends, with runOp on its own too', async () => { - const move: EditCommand = { op: 'move', ids: ['Activity_B', 'Activity_C'], after: 'Event_Start' }; + const move: EditCommand = { op: 'move', ids: ['task_b', 'task_c'], after: 'startEvent_start' }; const alone = await Doc.fromXml(TWO_OUT); alone.followFlowEnds = false; const single = runOp(alone, move); - // B had two out-flows (not bridged, removed): the move made Flow_BToA, then spliced it to B -> C - expect(single.renames.map((result) => `${result.from} -> ${result.to}`)).toEqual(['Flow_BToA -> Flow_BToC_2']); - expect(staleLines(single.warnings).sort()).toEqual(['Flow_CToDone Activity_C->Activity_A => Flow_CToA', 'Flow_StartToA Event_Start->Activity_B => Flow_StartToB']); + // B had two out-flows (not bridged, removed): the move made flow_bToA, then spliced it to B -> C + expect(single.renames.map((result) => `${result.from} -> ${result.to}`)).toEqual(['flow_bToA -> flow_bToC2']); + expect(staleLines(single.warnings).sort()).toEqual(['flow_cToDone task_c->task_a => flow_cToA', 'flow_startToA startEvent_start->task_b => flow_startToB']); const batch = await kept([move], TWO_OUT); expect(staleLines(batch.warnings).sort()).toEqual(staleLines(single.warnings).sort()); // 0.6 kept every id with Doc.followFlowEnds = false; now only the flows the document had const doc = await Doc.fromXml(CHAIN); doc.followFlowEnds = false; const cs = runOps(doc, [ - { op: 'add', kind: 'task', name: 'Review', after: 'Activity_Check' }, - { op: 'add', kind: 'task', name: 'Approve', after: 'Activity_Review' }, + { op: 'add', kind: 'task', name: 'Review', after: 'task_check' }, + { op: 'add', kind: 'task', name: 'Approve', after: 'task_review' }, ]); - expect(cs.renames.map((result) => `${result.from} -> ${result.to}`)).toEqual(['Flow_ReviewToOk -> Flow_ReviewToApprove']); - expect(cs.created.map((change) => change.id)).toContain('Flow_ReviewToApprove'); + expect(cs.renames.map((result) => `${result.from} -> ${result.to}`)).toEqual(['flow_reviewToOk -> flow_reviewToApprove']); + expect(cs.created.map((change) => change.id)).toContain('flow_reviewToApprove'); }); it('only the change reports it (warnings.added, never counted as the file\'s); text gives each stale flow its own line', async () => { const result = await applyToXml(await drawnDefault(), [ - { op: 'add', kind: 'task', name: 'Review', after: 'Activity_Check' }, - { op: 'add', kind: 'task', name: 'Prepare', before: 'Activity_Check' }, - { op: 'add', kind: 'task', name: 'Pay', after: 'Activity_Book' }, + { op: 'add', kind: 'task', name: 'Review', after: 'task_check' }, + { op: 'add', kind: 'task', name: 'Prepare', before: 'task_check' }, + { op: 'add', kind: 'task', name: 'Pay', after: 'task_book' }, ], { keepFlowIds: true }); expect(staleLines(result.result.warnings.added).sort()).toEqual([ - 'Flow_BookToDone Activity_Book->Activity_Pay => Flow_BookToPay', - 'Flow_CheckToOk Activity_Check->Activity_Review => Flow_CheckToReview', - 'Flow_StartToCheck Event_Start->Activity_Prepare => Flow_StartToPrepare', + 'flow_bookToDone task_book->task_pay => flow_bookToPay', + 'flow_checkToOk task_check->task_review => flow_checkToReview', + 'flow_startToCheck startEvent_start->task_prepare => flow_startToPrepare', ]); expect(result.result.warnings.preexistingCount).toBe(0); const text = renderMutation(result.result); expect(text.match(/^warning W_FLOW_ID_STALE /gm)).toHaveLength(3); - for (const cmd of ['id=Flow_BookToPay', 'id=Flow_CheckToReview', 'id=Flow_StartToPrepare']) expect(text).toContain(cmd); + for (const cmd of ['id=flow_bookToPay', 'id=flow_checkToReview', 'id=flow_startToPrepare']) expect(text).toContain(cmd); // the next, unrelated change does not repeat it - const next = await applyToXml(result.xml, [{ op: 'set', id: 'Activity_Pay', values: { name: 'Pay invoice' } }], { keepFlowIds: true }); + const next = await applyToXml(result.xml, [{ op: 'set', id: 'task_pay', values: { name: 'Pay invoice' } }], { keepFlowIds: true }); expect(stale(next.result.warnings.added)).toEqual([]); expect(next.result.warnings.preexistingCount).toBe(0); }); it('a later change that leaves a stale flow where it was does not repeat it, although the flow passes its old ends on the way', async () => { - const first = await applyToXml(LINE, [{ op: 'add', kind: 'task', name: 'X', after: 'Activity_A' }], { keepFlowIds: true, layout: false }); - expect(staleLines(first.result.warnings.added)).toEqual(['Flow_AToB Activity_A->Activity_X => Flow_AToX']); - // moving X away and back: the bridge runs Flow_AToB A -> B (the ends its id names), the splice A -> X again - const away: EditCommand[] = [{ op: 'move', ids: ['Activity_X'], after: 'Activity_C' }, { op: 'move', ids: ['Activity_X'], after: 'Activity_A' }]; + const first = await applyToXml(LINE, [{ op: 'add', kind: 'task', name: 'X', after: 'task_a' }], { keepFlowIds: true, layout: false }); + expect(staleLines(first.result.warnings.added)).toEqual(['flow_aToB task_a->task_x => flow_aToX']); + // moving X away and back: the bridge runs flow_aToB A -> B (the ends its id names), the splice A -> X again + const away: EditCommand[] = [{ op: 'move', ids: ['task_x'], after: 'task_c' }, { op: 'move', ids: ['task_x'], after: 'task_a' }]; const again = await applyToXml(first.xml, away, { keepFlowIds: true, layout: false }); expect(stale(again.result.warnings.added)).toEqual([]); const doc = await Doc.fromXml(first.xml); doc.followFlowEnds = false; expect(stale(runOps(doc, away).warnings)).toEqual([]); // a later op of the same change that gives it other ends again reports it - const moved = await kept([...away, { op: 'add', kind: 'task', name: 'Y', after: 'Activity_A' }], first.xml); - expect(staleLines(moved.warnings)).toEqual(['Flow_AToB Activity_A->Activity_Y => Flow_AToY']); + const moved = await kept([...away, { op: 'add', kind: 'task', name: 'Y', after: 'task_a' }], first.xml); + expect(staleLines(moved.warnings)).toEqual(['flow_aToB task_a->task_y => flow_aToY']); }); }); describe('an end renamed or named earlier in the change, and --flow-id on a splice', () => { /** CHAIN with a Camunda Modeler id for Check (its id says nothing: it is named by its name) and an unnamed Done. */ - const HASHED = CHAIN.replace(/Activity_Check/g, 'Activity_0abc123').replace('', ''); + const HASHED = CHAIN.replace(/task_check/g, 'Activity_0abc123').replace('', ''); /** `old -> new` of the renames by default, and `flow source->target => suggested id` of each W_FLOW_ID_STALE with ids kept. */ async function bothModes(xml: string, ops: EditCommand[]): Promise<{ renamed: string[]; stale: string[] }> { @@ -456,42 +454,42 @@ describe('an end renamed or named earlier in the change, and --flow-id on a spli it('an end `set id=` gave another id earlier in the batch: its flows follow, or with ids kept are reported, by default and with ids kept', async () => { const renamedThenSpliced = await bothModes(CHAIN, [ - { op: 'set', id: 'Activity_Check', values: { id: 'Activity_Verify' } }, - { op: 'add', kind: 'task', name: 'Log', after: 'Activity_Verify' }, + { op: 'set', id: 'task_check', values: { id: 'task_verify' } }, + { op: 'add', kind: 'task', name: 'Log', after: 'task_verify' }, ]); - expect(renamedThenSpliced).toEqual({ renamed: ['Flow_CheckToOk -> Flow_VerifyToLog'], stale: ['Flow_CheckToOk Activity_Verify->Activity_Log => Flow_VerifyToLog'] }); + expect(renamedThenSpliced).toEqual({ renamed: ['flow_checkToOk -> flow_verifyToLog'], stale: ['flow_checkToOk task_verify->task_log => flow_verifyToLog'] }); // --flow names the flow by its id, the target end too expect(await bothModes(CHAIN, [ - { op: 'set', id: 'Gateway_Ok', values: { id: 'Gateway_Approved' } }, - { op: 'add', kind: 'task', name: 'Audit', flow: 'Flow_CheckToOk' }, - ])).toEqual({ renamed: ['Flow_CheckToOk -> Flow_CheckToAudit'], stale: ['Flow_CheckToOk Activity_Check->Activity_Audit => Flow_CheckToAudit'] }); - // an unnamed end: only the id it had tells (Flow_BookToDone, Done renamed Finished); a remove bridges to it + { op: 'set', id: 'gateway_ok', values: { id: 'gateway_approved' } }, + { op: 'add', kind: 'task', name: 'Audit', flow: 'flow_checkToOk' }, + ])).toEqual({ renamed: ['flow_checkToOk -> flow_checkToAudit'], stale: ['flow_checkToOk task_check->task_audit => flow_checkToAudit'] }); + // an unnamed end: only the id it had tells (flow_bookToDone, Done renamed Finished); a remove bridges to it expect(await bothModes(HASHED, [ - { op: 'set', id: 'Event_Done', values: { id: 'Event_Finished' } }, - { op: 'add', kind: 'task', name: 'Log', before: 'Event_Finished' }, - ])).toEqual({ renamed: ['Flow_BookToDone -> Flow_BookToLog'], stale: ['Flow_BookToDone Activity_Book->Activity_Log => Flow_BookToLog'] }); + { op: 'set', id: 'endEvent_done', values: { id: 'endEvent_finished' } }, + { op: 'add', kind: 'task', name: 'Log', before: 'endEvent_finished' }, + ])).toEqual({ renamed: ['flow_bookToDone -> flow_bookToLog'], stale: ['flow_bookToDone task_book->task_log => flow_bookToLog'] }); expect(await bothModes(HASHED, [ - { op: 'set', id: 'Event_Done', values: { id: 'Event_Finished' } }, - { op: 'remove', ids: ['Activity_Book'] }, - ])).toEqual({ renamed: ['Flow_OkToBook -> Flow_OkToFinished'], stale: ['Flow_OkToBook Gateway_Ok->Event_Finished => Flow_OkToFinished'] }); + { op: 'set', id: 'endEvent_done', values: { id: 'endEvent_finished' } }, + { op: 'remove', ids: ['task_book'] }, + ])).toEqual({ renamed: ['flow_okToBook -> flow_okToFinished'], stale: ['flow_okToBook gateway_ok->endEvent_finished => flow_okToFinished'] }); }); it('an end `set name=` named again (its id says nothing), and `set id=` in a command before (the end keeps its name): the same', async () => { expect(await bothModes(HASHED, [ { op: 'set', id: 'Activity_0abc123', values: { name: 'Verify' } }, { op: 'add', kind: 'task', name: 'Log', after: 'Activity_0abc123' }, - ])).toEqual({ renamed: ['Flow_CheckToOk -> Flow_VerifyToLog'], stale: ['Flow_CheckToOk Activity_0abc123->Activity_Log => Flow_VerifyToLog'] }); + ])).toEqual({ renamed: ['flow_checkToOk -> flow_verifyToLog'], stale: ['flow_checkToOk Activity_0abc123->task_log => flow_verifyToLog'] }); // two commands: the first renames Check, the second splices after it; Check is still named Check - const first = await applyToXml(await drawnDefault(), [{ op: 'set', id: 'Activity_Check', values: { id: 'Activity_Verify' } }]); - expect(first.xml).toContain(''); - const splice: EditCommand[] = [{ op: 'add', kind: 'task', name: 'Log', after: 'Activity_Verify' }]; + const first = await applyToXml(await drawnDefault(), [{ op: 'set', id: 'task_check', values: { id: 'task_verify' } }]); + expect(first.xml).toContain(''); + const splice: EditCommand[] = [{ op: 'add', kind: 'task', name: 'Log', after: 'task_verify' }]; const renamed = await applyToXml(first.xml, splice); - expect(renamed.result.renamed).toEqual({ Flow_CheckToOk: 'Flow_VerifyToLog' }); - expect(renamed.xml).toContain(''); + expect(renamed.result.renamed).toEqual({ flow_checkToOk: 'flow_verifyToLog' }); + expect(renamed.xml).toContain(''); const keptIds = await applyToXml(first.xml, splice, { keepFlowIds: true }); - expect(staleLines(keptIds.result.warnings.added)).toEqual(['Flow_CheckToOk Activity_Verify->Activity_Log => Flow_VerifyToLog']); + expect(staleLines(keptIds.result.warnings.added)).toEqual(['flow_checkToOk task_verify->task_log => flow_verifyToLog']); // the warning's command renames it; nothing is stale afterwards - const followed = await applyToXml(keptIds.xml, [{ op: 'set', id: 'Flow_CheckToOk', values: { id: 'Flow_VerifyToLog' } }], { keepFlowIds: true }); + const followed = await applyToXml(keptIds.xml, [{ op: 'set', id: 'flow_checkToOk', values: { id: 'flow_verifyToLog' } }], { keepFlowIds: true }); expect(stale(followed.result.warnings.added)).toEqual([]); }); @@ -501,57 +499,57 @@ describe('an end renamed or named earlier in the change, and --flow-id on a spli { op: 'set', id: 'Activity_0abc123', values: { name: 'Verify' } }, retype, { op: 'add', kind: 'task', name: 'Log', after: 'Activity_0abc123' }, - ])).toEqual({ renamed: ['Flow_CheckToOk -> Flow_VerifyToLog'], stale: ['Flow_CheckToOk Activity_0abc123->Activity_Log => Flow_VerifyToLog'] }); + ])).toEqual({ renamed: ['flow_checkToOk -> flow_verifyToLog'], stale: ['flow_checkToOk Activity_0abc123->task_log => flow_verifyToLog'] }); } }); it('a renamed end changes nothing else: flows whose ends stay keep their ids, and a flow whose id names no ends stays', async () => { - // Book is renamed, the splice is after Check: Flow_CheckToOk follows as always, Flow_OkToBook and Flow_BookToDone keep their ids + // Book is renamed, the splice is after Check: flow_checkToOk follows as always, flow_okToBook and flow_bookToDone keep their ids const result = await bothModes(CHAIN, [ - { op: 'set', id: 'Activity_Book', values: { id: 'Activity_Post', name: 'Post' } }, - { op: 'add', kind: 'task', name: 'Log', after: 'Activity_Check' }, + { op: 'set', id: 'task_book', values: { id: 'task_post', name: 'Post' } }, + { op: 'add', kind: 'task', name: 'Log', after: 'task_check' }, ]); - expect(result).toEqual({ renamed: ['Flow_CheckToOk -> Flow_CheckToLog'], stale: ['Flow_CheckToOk Activity_Check->Activity_Log => Flow_CheckToLog'] }); - const own = await bothModes(CHAIN.replace(/Flow_CheckToOk/g, 'Flow_Review'), [ - { op: 'set', id: 'Activity_Check', values: { id: 'Activity_Verify' } }, - { op: 'add', kind: 'task', name: 'Log', after: 'Activity_Verify' }, + expect(result).toEqual({ renamed: ['flow_checkToOk -> flow_checkToLog'], stale: ['flow_checkToOk task_check->task_log => flow_checkToLog'] }); + const own = await bothModes(CHAIN.replace(/flow_checkToOk/g, 'flow_review'), [ + { op: 'set', id: 'task_check', values: { id: 'task_verify' } }, + { op: 'add', kind: 'task', name: 'Log', after: 'task_verify' }, ]); expect(own).toEqual({ renamed: [], stale: [] }); }); it('--flow-id on a splice with ids kept: W_FLOW_ID_STALE suggests it, W_OPTION_IGNORED names no second command, and every hint can be followed', async () => { - for (const placement of [{ flow: 'Flow_CheckToOk' }, { after: 'Activity_Check' }]) { - const result = await applyToXml(await drawnDefault(), [{ op: 'add', kind: 'task', name: 'Audit', ...placement, flowId: 'Flow_IntoAudit' }], { keepFlowIds: true }); + for (const placement of [{ flow: 'flow_checkToOk' }, { after: 'task_check' }]) { + const result = await applyToXml(await drawnDefault(), [{ op: 'add', kind: 'task', name: 'Audit', ...placement, flowId: 'flow_intoAudit' }], { keepFlowIds: true }); const ignored = result.result.warnings.added.filter((warning) => warning.code === 'W_OPTION_IGNORED'); - expect(ignored.map((warning) => warning.hint)).toEqual(['--flow-id only names a flow that add creates (after a gateway / unconnected node, before a join / unconnected node); W_FLOW_ID_STALE names the command that renames Flow_CheckToOk.']); - expect(staleLines(result.result.warnings.added)).toEqual(['Flow_CheckToOk Activity_Check->Activity_Audit => Flow_IntoAudit']); + expect(ignored.map((warning) => warning.hint)).toEqual(['--flow-id only names a flow that add creates (after a gateway / unconnected node, before a join / unconnected node); W_FLOW_ID_STALE names the command that renames flow_checkToOk.']); + expect(staleLines(result.result.warnings.added)).toEqual(['flow_checkToOk task_check->task_audit => flow_intoAudit']); // every command the warnings name, in order: one per flow, none fails const commands = result.result.warnings.added.flatMap((warning) => [...(warning.hint ?? '').matchAll(/`bpmn set (\S+) id=(\S+)`/g)]); - expect(commands.map((command) => `${command[1]} -> ${command[2]}`)).toEqual(['Flow_CheckToOk -> Flow_IntoAudit']); + expect(commands.map((command) => `${command[1]} -> ${command[2]}`)).toEqual(['flow_checkToOk -> flow_intoAudit']); const followed = await applyToXml(result.xml, commands.map((command) => ({ op: 'set', id: command[1]!, values: { id: command[2]! } })), { keepFlowIds: true }); - expect(followed.xml).toContain(''); + expect(followed.xml).toContain(''); } // by default the flow follows its ends and W_OPTION_IGNORED names the one rename, as before - const renamed = await applyToXml(await drawnDefault(), [{ op: 'add', kind: 'task', name: 'Audit', flow: 'Flow_CheckToOk', flowId: 'Flow_IntoAudit' }]); + const renamed = await applyToXml(await drawnDefault(), [{ op: 'add', kind: 'task', name: 'Audit', flow: 'flow_checkToOk', flowId: 'flow_intoAudit' }]); expect(renamed.result.warnings.added.map((warning) => `${warning.code}: ${warning.hint}`)).toEqual([ - 'W_OPTION_IGNORED: --flow-id only names a flow that add creates (after a gateway / unconnected node, before a join / unconnected node); rename the existing one with `bpmn set Flow_CheckToAudit id=Flow_IntoAudit`.', + 'W_OPTION_IGNORED: --flow-id only names a flow that add creates (after a gateway / unconnected node, before a join / unconnected node); rename the existing one with `bpmn set flow_checkToAudit id=flow_intoAudit`.', ]); }); it('--flow-id with ids kept: a taken id, a later splice into the same flow or a flow that does not name its ends give the id its ends give, or the old command', async () => { // taken: the speaking id - const taken = await kept([{ op: 'add', kind: 'task', name: 'Audit', flow: 'Flow_CheckToOk', flowId: 'Flow_StartToCheck' }]); - expect(staleLines(taken.warnings)).toEqual(['Flow_CheckToOk Activity_Check->Activity_Audit => Flow_CheckToAudit']); + const taken = await kept([{ op: 'add', kind: 'task', name: 'Audit', flow: 'flow_checkToOk', flowId: 'flow_startToCheck' }]); + expect(staleLines(taken.warnings)).toEqual(['flow_checkToOk task_check->task_audit => flow_checkToAudit']); // the flow no longer ends at Audit: Log was put in between, so --flow-id no longer describes it const later = await kept([ - { op: 'add', kind: 'task', name: 'Audit', flow: 'Flow_CheckToOk', flowId: 'Flow_IntoAudit' }, - { op: 'add', kind: 'task', name: 'Log', after: 'Activity_Check' }, + { op: 'add', kind: 'task', name: 'Audit', flow: 'flow_checkToOk', flowId: 'flow_intoAudit' }, + { op: 'add', kind: 'task', name: 'Log', after: 'task_check' }, ]); - expect(staleLines(later.warnings)).toEqual(['Flow_CheckToOk Activity_Check->Activity_Log => Flow_CheckToLog']); + expect(staleLines(later.warnings)).toEqual(['flow_checkToOk task_check->task_log => flow_checkToLog']); // a hashed flow keeps its id without going stale: W_OPTION_IGNORED keeps its own command - const hashed = await kept([{ op: 'add', kind: 'task', name: 'Pack', after: 'Activity_0k3x9qa', flowId: 'Flow_IntoPack' }], MODELER); + const hashed = await kept([{ op: 'add', kind: 'task', name: 'Pack', after: 'Activity_0k3x9qa', flowId: 'flow_intoPack' }], MODELER); expect(hashed.warnings.map((warning) => `${warning.code}: ${warning.hint}`)).toEqual([ - 'W_OPTION_IGNORED: --flow-id only names a flow that add creates (after a gateway / unconnected node, before a join / unconnected node); rename the existing one with `bpmn set Flow_1q2w3e4 id=Flow_IntoPack`.', + 'W_OPTION_IGNORED: --flow-id only names a flow that add creates (after a gateway / unconnected node, before a join / unconnected node); rename the existing one with `bpmn set Flow_1q2w3e4 id=flow_intoPack`.', ]); }); @@ -561,24 +559,24 @@ describe('an end renamed or named earlier in the change, and --flow-id on a spli `W_OPTION_IGNORED: --flow-id only names a flow that add creates (after a gateway / unconnected node, before a join / unconnected node); rename the existing one with \`bpmn set ${flow} id=${id}\`.`; const none = 'W_OPTION_IGNORED: --flow-id only names a flow that add creates (after a gateway / unconnected node, before a join / unconnected node).'; // the kept flow still names its new ends: a task named Ok before the gateway Ok?, an unnamed gateway before the unnamed Gateway_1 - expect(hints((await kept([{ op: 'add', kind: 'task', name: 'Ok', flow: 'Flow_CheckToOk', flowId: 'Flow_IntoOk' }])).warnings)).toEqual([own('Flow_CheckToOk', 'Flow_IntoOk')]); - const unnamed = CHAIN.replace(' hint.startsWith('W_OPTION_IGNORED'))).toEqual([none]); }); @@ -586,40 +584,40 @@ describe('an end renamed or named earlier in the change, and --flow-id on a spli /** LINE where B -> C has a label and a condition. */ const NAMED_LINE = LINE.replace( - '', - '${approved}', + '', + '${approved}', ); /** Start -> T -> Sub (SubStart -> Inner -> SubEnd) -> Done. */ const WITH_SUB = definitionsXml(` - Flow_StartToT - Flow_StartToTFlow_TToSub - Flow_TToSubFlow_SubToDone - Flow_SubStartToInner - Flow_SubStartToInnerFlow_InnerToSubEnd - Flow_InnerToSubEnd - - + flow_startToT + flow_startToTflow_tToSub + flow_tToSubflow_subToDone + flow_subStartToInner + flow_subStartToInnerflow_innerToSubEnd + flow_innerToSubEnd + + - Flow_SubToDone - - - `); + flow_subToDone + + + `); /** Start -> A -> T -> Done; a timer on T leads to Remind -> Late. */ const WITH_TIMER = definitionsXml(` - Flow_StartToA - Flow_StartToAFlow_AToT - Flow_AToTFlow_TToDone - Flow_TimeoutToRemind - Flow_TimeoutToRemindFlow_RemindToLate - Flow_TToDone - Flow_RemindToLate - - - - - `); + flow_startToA + flow_startToAflow_aToT + flow_aToTflow_tToDone + flow_timeoutToRemind + flow_timeoutToRemindflow_remindToLate + flow_tToDone + flow_remindToLate + + + + + `); /** `: ` of the changed sequence flows. */ const changedFlows = (cs: Pick): string[] => cs.changed.filter((change) => change.kind === 'sequenceFlow').map((change) => `${change.id}: ${change.detail}`); @@ -627,18 +625,18 @@ const changedFlows = (cs: Pick): string[] => cs.changed.fi describe('move: a chain anchored on its own nodes, nodes already in place, what the bridge took', () => { it('a chain whose --after anchor or --flow source is one of its later nodes, or inside one, is refused before anything changes, in both modes', async () => { const cases: Array<[string, EditCommand, string, string]> = [ - [JOINED, { op: 'move', ids: ['Activity_A', 'Gateway_Join', 'Event_Done'], after: 'Gateway_Join' }, 'Gateway_Join cannot anchor the move of Activity_A, Gateway_Join, Event_Done: it is one of the moved nodes', '`bpmn move Activity_A --after Gateway_Join`'], - [LINE, { op: 'move', ids: ['Activity_A', 'Activity_B'], flow: 'Flow_BToC' }, 'Flow_BToC cannot anchor the move of Activity_A, Activity_B: its source Activity_B is one of the moved nodes', '`bpmn move Activity_A --flow Flow_BToC`'], - [LINE, { op: 'move', ids: ['Activity_B', 'Activity_C'], after: 'Activity_C' }, 'Activity_C cannot anchor the move of Activity_B, Activity_C: it is one of the moved nodes', '`bpmn move Activity_B --after Activity_C`'], + [JOINED, { op: 'move', ids: ['task_a', 'gateway_join', 'endEvent_done'], after: 'gateway_join' }, 'gateway_join cannot anchor the move of task_a, gateway_join, endEvent_done: it is one of the moved nodes', '`bpmn move task_a --after gateway_join`'], + [LINE, { op: 'move', ids: ['task_a', 'task_b'], flow: 'flow_bToC' }, 'flow_bToC cannot anchor the move of task_a, task_b: its source task_b is one of the moved nodes', '`bpmn move task_a --flow flow_bToC`'], + [LINE, { op: 'move', ids: ['task_b', 'task_c'], after: 'task_c' }, 'task_c cannot anchor the move of task_b, task_c: it is one of the moved nodes', '`bpmn move task_b --after task_c`'], // C alone is already after B: the hint has the reorder instead - [LINE, { op: 'move', ids: ['Activity_C', 'Activity_B'], after: 'Activity_B' }, 'Activity_B cannot anchor the move of Activity_C, Activity_B: it is one of the moved nodes', 'Activity_C is already after Activity_B. Pick an anchor outside the moved nodes, or put Activity_C before Activity_B: `bpmn move Activity_C --before Activity_B`.'], - [LINE, { op: 'move', ids: ['Activity_A', 'Activity_B'], after: 'Activity_B', before: 'Activity_C' }, 'Activity_B cannot anchor the move of Activity_A, Activity_B: it is one of the moved nodes', '`bpmn move Activity_A --after Activity_B --before Activity_C`'], - [WITH_SUB, { op: 'move', ids: ['Activity_T', 'Activity_Sub'], after: 'Activity_Inner' }, 'Activity_Inner cannot anchor the move of Activity_T, Activity_Sub: it is inside Activity_Sub, one of the moved nodes', '`bpmn move Activity_T --after Activity_Inner`'], + [LINE, { op: 'move', ids: ['task_c', 'task_b'], after: 'task_b' }, 'task_b cannot anchor the move of task_c, task_b: it is one of the moved nodes', 'task_c is already after task_b. Pick an anchor outside the moved nodes, or put task_c before task_b: `bpmn move task_c --before task_b`.'], + [LINE, { op: 'move', ids: ['task_a', 'task_b'], after: 'task_b', before: 'task_c' }, 'task_b cannot anchor the move of task_a, task_b: it is one of the moved nodes', '`bpmn move task_a --after task_b --before task_c`'], + [WITH_SUB, { op: 'move', ids: ['task_t', 'subProcess_sub'], after: 'task_inner' }, 'task_inner cannot anchor the move of task_t, subProcess_sub: it is inside subProcess_sub, one of the moved nodes', '`bpmn move task_t --after task_inner`'], // a --before anchor inside a later node: the chain would be placed inside itself - [WITH_SUB, { op: 'move', ids: ['Activity_T', 'Activity_Sub'], before: 'Activity_Inner' }, 'Activity_Inner cannot anchor the move of Activity_T, Activity_Sub: it is inside Activity_Sub, one of the moved nodes', 'placed inside itself. Pick an anchor outside the moved nodes, or move Activity_T alone: `bpmn move Activity_T --before Activity_Inner`'], + [WITH_SUB, { op: 'move', ids: ['task_t', 'subProcess_sub'], before: 'task_inner' }, 'task_inner cannot anchor the move of task_t, subProcess_sub: it is inside subProcess_sub, one of the moved nodes', 'placed inside itself. Pick an anchor outside the moved nodes, or move task_t alone: `bpmn move task_t --before task_inner`'], // a boundary event moves with its host - [WITH_TIMER, { op: 'move', ids: ['Activity_A', 'Activity_T'], after: 'Event_Timeout' }, 'Event_Timeout cannot anchor the move of Activity_A, Activity_T: it is a boundary event of Activity_T, one of the moved nodes', '`bpmn move Activity_A --after Event_Timeout`'], - [WITH_TIMER, { op: 'move', ids: ['Activity_A', 'Activity_T'], flow: 'Flow_TimeoutToRemind' }, 'Flow_TimeoutToRemind cannot anchor the move of Activity_A, Activity_T: its source Event_Timeout is a boundary event of Activity_T, one of the moved nodes', '`bpmn move Activity_A --flow Flow_TimeoutToRemind`'], + [WITH_TIMER, { op: 'move', ids: ['task_a', 'task_t'], after: 'event_timeout' }, 'event_timeout cannot anchor the move of task_a, task_t: it is a boundary event of task_t, one of the moved nodes', '`bpmn move task_a --after event_timeout`'], + [WITH_TIMER, { op: 'move', ids: ['task_a', 'task_t'], flow: 'flow_timeoutToRemind' }, 'flow_timeoutToRemind cannot anchor the move of task_a, task_t: its source event_timeout is a boundary event of task_t, one of the moved nodes', '`bpmn move task_a --flow flow_timeoutToRemind`'], ]; for (const [xml, op, message, command] of cases) { for (const keepFlowIds of [true, false]) { @@ -655,22 +653,22 @@ describe('move: a chain anchored on its own nodes, nodes already in place, what } } // the node itself as the anchor keeps its own message, and so does its own boundary event; a --before anchor in the chain is fine (it ends A -> Join) - await expect(applyToXml(LINE, [{ op: 'move', ids: ['Activity_B'], after: 'Activity_B' }], { layout: false })).rejects.toMatchObject({ code: 'E_INVALID_PLACEMENT', message: 'Activity_B cannot be moved relative to Activity_B: that is the node itself or one of its children' }); - await expect(applyToXml(WITH_TIMER, [{ op: 'move', ids: ['Activity_T'], after: 'Event_Timeout' }], { layout: false })).rejects.toMatchObject({ code: 'E_INVALID_PLACEMENT', message: 'Activity_T cannot be moved relative to Event_Timeout: that is one of its boundary events, which move with it' }); - const afterTimer = await kept([{ op: 'move', ids: ['Activity_A'], after: 'Event_Timeout' }], WITH_TIMER); - expect([flowBetween(afterTimer.doc, 'Event_Timeout', 'Activity_A'), flowBetween(afterTimer.doc, 'Activity_A', 'Activity_Remind')]).toEqual(['Flow_TimeoutToRemind', 'Flow_AToT']); - const before = await kept([{ op: 'move', ids: ['Activity_A', 'Gateway_Join'], before: 'Gateway_Join' }], JOINED); - expect(flowBetween(before.doc, 'Activity_A', 'Gateway_Join')).toBe('Flow_AToJoin'); + await expect(applyToXml(LINE, [{ op: 'move', ids: ['task_b'], after: 'task_b' }], { layout: false })).rejects.toMatchObject({ code: 'E_INVALID_PLACEMENT', message: 'task_b cannot be moved relative to task_b: that is the node itself or one of its children' }); + await expect(applyToXml(WITH_TIMER, [{ op: 'move', ids: ['task_t'], after: 'event_timeout' }], { layout: false })).rejects.toMatchObject({ code: 'E_INVALID_PLACEMENT', message: 'task_t cannot be moved relative to event_timeout: that is one of its boundary events, which move with it' }); + const afterTimer = await kept([{ op: 'move', ids: ['task_a'], after: 'event_timeout' }], WITH_TIMER); + expect([flowBetween(afterTimer.doc, 'event_timeout', 'task_a'), flowBetween(afterTimer.doc, 'task_a', 'task_remind')]).toEqual(['flow_timeoutToRemind', 'flow_aToT']); + const before = await kept([{ op: 'move', ids: ['task_a', 'gateway_join'], before: 'gateway_join' }], JOINED); + expect(flowBetween(before.doc, 'task_a', 'gateway_join')).toBe('flow_aToJoin'); }); it('a move that leaves its nodes where they are changes nothing, in both modes: no write, no warning, the label and condition stay', async () => { const cases: Array<[EditCommand, string]> = [ - [{ op: 'move', ids: ['Activity_B'], after: 'Activity_A' }, 'Activity_B is already after Activity_A'], - [{ op: 'move', ids: ['Activity_B', 'Activity_C'], after: 'Activity_A' }, 'Activity_B, Activity_C are already after Activity_A, in this order'], - [{ op: 'move', ids: ['Activity_B'], before: 'Activity_C' }, 'Activity_B is already before Activity_C'], - [{ op: 'move', ids: ['Activity_B', 'Activity_C'], before: 'Event_Done' }, 'Activity_B, Activity_C are already before Event_Done, in this order'], - [{ op: 'move', ids: ['Activity_B'], after: 'Activity_A', before: 'Activity_C' }, 'Activity_B is already between Activity_A and Activity_C'], - [{ op: 'move', ids: ['Activity_C', 'Event_Done'], after: 'Activity_B' }, 'Activity_C, Event_Done are already after Activity_B, in this order'], + [{ op: 'move', ids: ['task_b'], after: 'task_a' }, 'task_b is already after task_a'], + [{ op: 'move', ids: ['task_b', 'task_c'], after: 'task_a' }, 'task_b, task_c are already after task_a, in this order'], + [{ op: 'move', ids: ['task_b'], before: 'task_c' }, 'task_b is already before task_c'], + [{ op: 'move', ids: ['task_b', 'task_c'], before: 'endEvent_done' }, 'task_b, task_c are already before endEvent_done, in this order'], + [{ op: 'move', ids: ['task_b'], after: 'task_a', before: 'task_c' }, 'task_b is already between task_a and task_c'], + [{ op: 'move', ids: ['task_c', 'endEvent_done'], after: 'task_b' }, 'task_c, endEvent_done are already after task_b, in this order'], ]; for (const [op, note] of cases) { for (const keepFlowIds of [true, false]) { @@ -685,9 +683,9 @@ describe('move: a chain anchored on its own nodes, nodes already in place, what // a drawn file with its layout: nothing to write either const drawn = await drawnDefault(); for (const keepFlowIds of [true, false]) { - const result = await applyToXml(drawn, [{ op: 'move', ids: ['Activity_Check'], after: 'Event_Start' }], { keepFlowIds }); + const result = await applyToXml(drawn, [{ op: 'move', ids: ['task_check'], after: 'startEvent_start' }], { keepFlowIds }); expect(result.unchanged).toBe(true); - expect(result.result.notes).toContain('Activity_Check is already after Event_Start'); + expect(result.result.notes).toContain('task_check is already after startEvent_start'); } // a hand-made drawing keeps every shape: the incremental layout places no node a move left where it was (--in its own scope too) const hand = readFileSync(join(join(dirname(fileURLToPath(import.meta.url)), '..', '..'), 'fixtures', 'step1', 'far-label.bpmn'), 'utf8'); @@ -697,176 +695,109 @@ describe('move: a chain anchored on its own nodes, nodes already in place, what } // flow options describe the flow into the first node, as on a splice: only that flow changes for (const keepFlowIds of [true, false]) { - const result = await applyToXml(NAMED_LINE, [{ op: 'move', ids: ['Activity_B'], after: 'Activity_A', flowName: 'start' }], { keepFlowIds, layout: false }); - expect(changedFlows(result.result)).toEqual(['Flow_AToB: Activity_A -> Activity_B']); - expect(result.xml).toContain(''); - expect(result.xml).toMatch(/\s* task_b']); + expect(result.xml).toContain(''); + expect(result.xml).toMatch(/\s* { for (const keepFlowIds of [true, false]) { - const result = await applyToXml(LINE, [{ op: 'move', ids: ['Event_Done'], after: 'Activity_C', flowId: 'Flow_CToEnd' }], { keepFlowIds, layout: false }); + const result = await applyToXml(LINE, [{ op: 'move', ids: ['endEvent_done'], after: 'task_c', flowId: 'flow_cToEnd' }], { keepFlowIds, layout: false }); expect([result.result.created, result.result.removed, result.result.warnings.added], `keep=${keepFlowIds}`).toEqual([[], [], []]); - expect(changedFlows(result.result)).toEqual(['Flow_CToEnd: Activity_C -> Event_Done (renamed from Flow_CToDone)']); - expect(result.result.notes).toEqual(['Event_Done is already after Activity_C']); - expect(result.xml).toContain(''); - expect(result.xml).not.toContain('Flow_CToDone'); + expect(changedFlows(result.result)).toEqual(['flow_cToEnd: task_c -> endEvent_done (renamed from flow_cToDone)']); + expect(result.result.notes).toEqual(['endEvent_done is already after task_c']); + expect(result.xml).toContain(''); + expect(result.xml).not.toContain('flow_cToDone'); // with a name: one entry; the id of another element is refused - const named = await applyToXml(NAMED_LINE, [{ op: 'move', ids: ['Activity_B'], after: 'Activity_A', flowId: 'Flow_Start', flowName: 'start' }], { keepFlowIds, layout: false }); - expect(changedFlows(named.result)).toEqual(['Flow_Start: Activity_A -> Activity_B (renamed from Flow_AToB)']); - expect(named.xml).toContain(''); - await expect(applyToXml(LINE, [{ op: 'move', ids: ['Activity_B'], after: 'Activity_A', flowId: 'Flow_BToC' }], { keepFlowIds, layout: false })).rejects.toMatchObject({ code: 'E_DUPLICATE_ID' }); + const named = await applyToXml(NAMED_LINE, [{ op: 'move', ids: ['task_b'], after: 'task_a', flowId: 'flow_start', flowName: 'start' }], { keepFlowIds, layout: false }); + expect(changedFlows(named.result)).toEqual(['flow_start: task_a -> task_b (renamed from flow_aToB)']); + expect(named.xml).toContain(''); + await expect(applyToXml(LINE, [{ op: 'move', ids: ['task_b'], after: 'task_a', flowId: 'flow_bToC' }], { keepFlowIds, layout: false })).rejects.toMatchObject({ code: 'E_DUPLICATE_ID' }); } }); it('with ids kept, the change of a bridged node\'s put-back out-flow names what the bridge took: its name, condition and annotations', async () => { - const move: EditCommand[] = [{ op: 'move', ids: ['Activity_B'], after: 'Activity_C' }]; + const move: EditCommand[] = [{ op: 'move', ids: ['task_b'], after: 'task_c' }]; const both = await applyToXml(NAMED_LINE, move, { keepFlowIds: true, layout: false }); expect(changedFlows(both.result)).toEqual([ - 'Flow_AToB: Activity_A -> Activity_C (bridged Activity_B)', - 'Flow_CToDone: Activity_C -> Activity_B (was -> Event_Done)', - 'Flow_BToC: Activity_B -> Event_Done (was -> Activity_C; its name and condition stay with the bridge Flow_AToB)', + 'flow_aToB: task_a -> task_c (bridged task_b)', + 'flow_cToDone: task_c -> task_b (was -> endEvent_done)', + 'flow_bToC: task_b -> endEvent_done (was -> task_c; its name and condition stay with the bridge flow_aToB)', ]); - expect(both.xml).toMatch(/\s* Event_Done (was -> Activity_C; its name and condition stay with the bridge Flow_AToB)'); + expect(both.xml).toMatch(/\s* endEvent_done (was -> task_c; its name and condition stay with the bridge flow_aToB)'); // an annotation goes with them; a name the bridge already had stays, so only the rest is named - const annotated = NAMED_LINE.replace('note\n \n note\n \n Event_Done (was -> Activity_C; its condition and annotation stay with the bridge Flow_AToB)'); + expect(changedFlows(some.result)).toContain('flow_bToC: task_b -> endEvent_done (was -> task_c; its condition and annotation stay with the bridge flow_aToB)'); expect(some.result.warnings.added.map((warning) => warning.code)).toContain('W_LABEL_DROPPED'); // the bridge is the default flow of A: the condition is dropped there (W_CONDITION_DROPPED), so only the name is named - const byDefault = NAMED_LINE.replace('', ''); + const byDefault = NAMED_LINE.replace('', ''); const named = await applyToXml(byDefault, move, { keepFlowIds: true, layout: false }); - expect(changedFlows(named.result)).toContain('Flow_BToC: Activity_B -> Event_Done (was -> Activity_C; its name stays with the bridge Flow_AToB)'); + expect(changedFlows(named.result)).toContain('flow_bToC: task_b -> endEvent_done (was -> task_c; its name stays with the bridge flow_aToB)'); // put back between its old ends (before the join, a prepend): the clause alone - const join = JOINED.replace(' Gateway_Join (its name stays with the bridge Flow_OkToA)'); + const join = JOINED.replace(' gateway_join (its name stays with the bridge flow_okToA)'); // nothing to name: as before; by default the out-flow is removed with its label, as before - expect(changedFlows(await applyToXml(LINE, move, { keepFlowIds: true, layout: false }).then((result) => result.result))).toContain('Flow_BToC: Activity_B -> Event_Done (was -> Activity_C)'); + expect(changedFlows(await applyToXml(LINE, move, { keepFlowIds: true, layout: false }).then((result) => result.result))).toContain('flow_bToC: task_b -> endEvent_done (was -> task_c)'); const renamed = await applyToXml(NAMED_LINE, move, { layout: false }); - expect(renamed.result.removed).toContainEqual({ id: 'Flow_BToC', kind: 'sequenceFlow', name: 'approved', detail: 'Activity_B -> Activity_C' }); + expect(renamed.result.removed).toContainEqual({ id: 'flow_bToC', kind: 'sequenceFlow', name: 'approved', detail: 'task_b -> task_c' }); expect(changedFlows(renamed.result).join('\n')).not.toContain('stay'); }); it('with ids kept, the clause is decided once the chain is placed: a bridge a later node removed, renamed or took it back from', async () => { - // A's bridge T -> Done takes "done", then Done's detach (an end event) removes it: Flow_AToDone, back between A and Done, gets it back + // A's bridge T -> Done takes "done", then Done's detach (an end event) removes it: flow_aToDone, back between A and Done, gets it back const branched = definitionsXml(` - Flow_StartToOk - Flow_StartToOkFlow_OkToTFlow_OkToU - Flow_OkToTFlow_TToA - Flow_TToAFlow_AToDone - Flow_AToDone - Flow_OkToUFlow_UToOther - Flow_UToOther - - - - \${done} - - `); - const removed = await applyToXml(branched, [{ op: 'move', ids: ['Activity_A', 'Event_Done'], after: 'Gateway_Ok' }], { keepFlowIds: true, layout: false }); - expect(removed.xml).toMatch(/\s* Event_Done (bridged Activity_A)', 'Flow_AToDone: Activity_A -> Event_Done']); - expect(removed.result.removed.map((change) => change.id)).toEqual(['Flow_TToA']); + flow_startToOk + flow_startToOkflow_okToTflow_okToU + flow_okToTflow_tToA + flow_tToAflow_aToDone + flow_aToDone + flow_okToUflow_uToOther + flow_uToOther + + + + \${done} + + `); + const removed = await applyToXml(branched, [{ op: 'move', ids: ['task_a', 'endEvent_done'], after: 'gateway_ok' }], { keepFlowIds: true, layout: false }); + expect(removed.xml).toMatch(/\s* endEvent_done (bridged task_a)', 'flow_aToDone: task_a -> endEvent_done']); + expect(removed.result.removed.map((change) => change.id)).toEqual(['flow_tToA']); expect(removed.result.warnings.added.filter((warning) => warning.code.endsWith('_DROPPED'))).toEqual([]); // T's bridge Ok -> A takes "next", then Ok's detach (a split: not bridged) removes it, and T -> A ends at Ok now: the label is dropped, and said so - const named = branched.replace('id="Flow_TToA" sourceRef', 'id="Flow_TToA" name="next" sourceRef'); - const lost = await applyToXml(named, [{ op: 'move', ids: ['Activity_T', 'Gateway_Ok'], after: 'Activity_U' }], { keepFlowIds: true, layout: false }); + const named = branched.replace('id="flow_tToA" sourceRef', 'id="flow_tToA" name="next" sourceRef'); + const lost = await applyToXml(named, [{ op: 'move', ids: ['task_t', 'gateway_ok'], after: 'task_u' }], { keepFlowIds: true, layout: false }); expect(lost.xml).not.toContain('"next"'); expect(lost.result.warnings.added.filter((warning) => warning.code === 'W_LABEL_DROPPED').map((warning) => warning.message)).toEqual([ - 'Flow label "next" of Flow_TToA was dropped while moving Activity_T: the bridge Flow_OkToT took it, and a later node of the move removed Flow_OkToT', + 'Flow label "next" of flow_tToA was dropped while moving task_t: the bridge flow_okToT took it, and a later node of the move removed flow_okToT', ]); expect(changedFlows(lost.result).join('\n')).not.toContain('stay'); // a bridge a later node renamed (a flow the batch created follows its ends): the clause names the id it has now - const long = NAMED_LINE.replace('Flow_CToDone', 'Flow_CToD\n Flow_CToDFlow_DToDone') - .replace('Flow_CToDone', 'Flow_DToDone') - .replace('', '\n '); - const batch = await applyToXml(long, [{ op: 'add', kind: 'task', name: 'X', after: 'Activity_A' }, { op: 'move', ids: ['Activity_B', 'Activity_C'], after: 'Activity_D' }], { keepFlowIds: true, layout: false }); - expect(batch.xml).toMatch(//); - expect(changedFlows(batch.result).filter((flowLine) => flowLine.includes('stay'))).toEqual(['Flow_BToC: Activity_B -> Activity_C (was -> Event_Done; its name and condition stay with the bridge Flow_XToD)']); - // B and A swap places: the second bridge takes the label back to Flow_BToC, so nothing is said, and each entry is listed once - const swap = await applyToXml(NAMED_LINE, [{ op: 'move', ids: ['Activity_B', 'Activity_A'], after: 'Event_Start' }], { keepFlowIds: true, layout: false }); + const long = NAMED_LINE.replace('flow_cToDone', 'flow_cToD\n flow_cToDflow_dToDone') + .replace('flow_cToDone', 'flow_dToDone') + .replace('', '\n '); + const batch = await applyToXml(long, [{ op: 'add', kind: 'task', name: 'X', after: 'task_a' }, { op: 'move', ids: ['task_b', 'task_c'], after: 'task_d' }], { keepFlowIds: true, layout: false }); + expect(batch.xml).toMatch(//); + expect(changedFlows(batch.result).filter((flowLine) => flowLine.includes('stay'))).toEqual(['flow_bToC: task_b -> task_c (was -> endEvent_done; its name and condition stay with the bridge flow_xToD)']); + // B and A swap places: the second bridge takes the label back to flow_bToC, so nothing is said, and each entry is listed once + const swap = await applyToXml(NAMED_LINE, [{ op: 'move', ids: ['task_b', 'task_a'], after: 'startEvent_start' }], { keepFlowIds: true, layout: false }); expect(changedFlows(swap.result)).toEqual([ - 'Flow_AToB: Activity_A -> Activity_C (bridged Activity_B)', - 'Flow_StartToA: Event_Start -> Activity_B (was -> Activity_A)', - 'Flow_BToC: Activity_B -> Activity_A (was -> Activity_C)', - 'Flow_BToC: Activity_B -> Activity_C (bridged Activity_A)', - 'Flow_AToB: Activity_A -> Activity_C', + 'flow_aToB: task_a -> task_c (bridged task_b)', + 'flow_startToA: startEvent_start -> task_b (was -> task_a)', + 'flow_bToC: task_b -> task_a (was -> task_c)', + 'flow_bToC: task_b -> task_c (bridged task_a)', + 'flow_aToB: task_a -> task_c', ]); - expect(swap.xml).toMatch(/\s* { - const fixture = (...path: string[]): string => readFileSync(join(join(dirname(fileURLToPath(import.meta.url)), '..', '..'), 'fixtures', ...path), 'utf8'); - - /** Follows every W_FLOW_ID_STALE hint of `res` (`bpmn set id=`) and reads the result again. */ - async function follow(xml: string, warnings: readonly Finding[]): Promise<{ followed: string[]; naming: string[]; before: unknown; after: unknown }> { - const ops: EditCommand[] = stale(warnings).map((warning) => ({ op: 'set', id: warning.element!, values: { id: / id=(\S+)`/.exec(warning.hint!)![1]! } })); - const done = await applyToXml(xml, ops, { keepFlowIds: true, layout: false }); - const doc = await Doc.fromXml(done.xml); - const naming = ops.flatMap((op) => { - const id = (op as { values: { id: string } }).values.id; - const flow = doc.get(id)!; - return new FlowIdFollower(new EditSession(doc)).namesEnds(id, flow.get('sourceRef'), flow.get('targetRef')) ? [id] : []; - }); - return { followed: ops.map((op) => (op as { values: { id: string } }).values.id), naming, before: (await Doc.fromXml(xml)).ownIdStyle.info().sequenceFlow, after: doc.ownIdStyle.info().sequenceFlow }; - } - - // the five 'follow hints' violations of the keep-mode fuzz campaigns (seeds 1003, 9456, 9473, 7392, 7922): each followed - // id named its ends no more on the next read, because the file's flow prefix flipped (f -> Flow_: bpmn-cli's glued flows - // did not vote; G or F, tied: the first declared won) - const CASES: Array<[string, string, EditCommand]> = [ - ['polish-remove-9456.bpmn', 'fHandleTicketToFz6Archive', { op: 'add', kind: 'subProcess', name: 'Fz6 archive', before: 'Activity_Fz3Pay' }], - ['expand-sibling-9473.bpmn', 'G_Fz6ArchiveToFz3Check G_Fz3CheckToThen', { op: 'move', ids: ['Activity_Fz3Check', 'T', 'P'], before: 'E' }], - ['polish-lanes-7392.bpmn', 'fFz5CheckToReviewLarge', { op: 'move', ids: ['Gateway_Fz5Check', 'T2'], after: 'G1' }], - ['pool-branch-7922.bpmn', 'F_CarrierToParcel', { op: 'move', ids: ['T_UP', 'T_LOW'], before: 'E1', flowName: 'fn4', default: true }], - ]; - it.each(CASES)('%s: the followed ids (%s) name their ends on the next read', async (file, ids, op) => { - const xml = fixture('mixed-styles', file); - const res = await applyToXml(xml, [op], { keepFlowIds: true, layout: false }); - const { followed, naming, before, after } = await follow(res.xml, res.result.warnings.added); - expect(followed.join(' ')).toBe(ids); - expect(naming).toEqual(followed); - expect(after).toEqual(before); - }); - - it('seed 1003: six commands on polish-remove.bpmn, the split hint followed, still names its ends', async () => { - const ops: EditCommand[] = [ - { op: 'move', ids: ['H'], after: 'M' }, - { op: 'add', kind: 'task', name: 'Fz2 check', after: 'S' }, - { op: 'move', ids: ['H'], before: 'E2' }, - { op: 'add', kind: 'userTask', name: 'Fz4 notify', before: 'H' }, - { op: 'add', kind: 'serviceTask', name: 'Fz5 review', after: 'S' }, - { op: 'split', after: 'Activity_Fz5Review', branches: [{ nodes: [{ kind: 'task', name: 'Fz6 a' }] }, { nodes: [{ kind: 'task', name: 'Fz6 b' }] }] }, - ]; - let xml = fixture('incremental', 'polish-remove.bpmn'); - let warnings: readonly Finding[] = []; - for (const op of ops) { - const res = await applyToXml(xml, [op], { keepFlowIds: true, layout: false }); - xml = res.xml; - warnings = res.result.warnings.added; - } - const { followed, naming, after } = await follow(xml, warnings); - expect(followed).toEqual(['fFz5ReviewToAfterFz5Review']); - expect(naming).toEqual(followed); - expect(after).toEqual({ form: 'named', prefix: 'f', sep: '' }); - }); - - it('by default too: the flows of an f1 file keep f after a splice, and the next splice renames the flow it splits', async () => { - const xml = fixture('mixed-styles', 'polish-remove-9456.bpmn'); - const first = await applyToXml(xml, [{ op: 'add', kind: 'subProcess', name: 'Fz6 archive', before: 'Activity_Fz3Pay' }], { layout: false }); - expect(first.result.renamed).toEqual({ fHandleTicketToFz3Pay: 'fHandleTicketToFz6Archive' }); - // before: the next read learned Flow_, created Flow_Fz7FileToFz3Pay and kept fFz6ArchiveToFz3Pay on Fz6Archive -> Fz7File - const second = await applyToXml(first.xml, [{ op: 'add', kind: 'task', name: 'Fz7 file', after: 'Activity_Fz6Archive' }], { layout: false }); - expect(second.result.renamed).toEqual({ fFz6ArchiveToFz3Pay: 'fFz6ArchiveToFz7File' }); - expect(second.result.created.map((change) => change.id)).toEqual(['Activity_Fz7File', 'fFz7FileToFz3Pay']); + expect(swap.xml).toMatch(/\s* { /** DI id by element id. */ const diOf = (xml: string): Map => new Map([...xml.matchAll(/ [match[2]!, match[1]!])); const RENAMES: EditCommand[] = [ - { op: 'set', id: 'Flow_CheckToOk', values: { id: 'Flow_CheckToAudit' } }, - { op: 'set', id: 'Activity_Check', values: { id: 'Activity_Audit' } }, + { op: 'set', id: 'flow_checkToOk', values: { id: 'flow_checkToAudit' } }, + { op: 'set', id: 'task_check', values: { id: 'task_audit' } }, ]; it('on a flow and a task, in the `_di` and `BPMN_` styles, with ids kept too; the result is unchanged', async () => { @@ -883,39 +814,39 @@ describe('`set id=` takes a DI id derived from the old id along', () => { for (const keepFlowIds of [false, true]) { const result = await applyToXml(await drawnDefault(style), RENAMES, { keepFlowIds }); const di = diOf(result.xml); - expect(di.get('Flow_CheckToAudit')).toBe(style === 'suffix' ? 'Flow_CheckToAudit_di' : 'BPMNEdge_Flow_CheckToAudit'); - expect(di.get('Activity_Audit')).toBe(style === 'suffix' ? 'Activity_Audit_di' : 'BPMNShape_Activity_Audit'); - expect(result.xml).not.toMatch(/Activity_Check|Flow_CheckToOk/); - expect(result.result.changed.map((change) => `${change.id}: ${change.detail}`)).toEqual(['Flow_CheckToAudit: renamed from Flow_CheckToOk', 'Activity_Audit: renamed from Activity_Check']); + expect(di.get('flow_checkToAudit')).toBe(style === 'suffix' ? 'flow_checkToAudit_di' : 'BPMNEdge_flow_checkToAudit'); + expect(di.get('task_audit')).toBe(style === 'suffix' ? 'task_audit_di' : 'BPMNShape_task_audit'); + expect(result.xml).not.toMatch(/task_check|flow_checkToOk/); + expect(result.result.changed.map((change) => `${change.id}: ${change.detail}`)).toEqual(['flow_checkToAudit: renamed from flow_checkToOk', 'task_audit: renamed from task_check']); } } }); it('a DI id not derived from the old id stays; one whose new id is taken gets _2', async () => { - const xml = (await drawnDefault()).replace('id="Activity_Check_di"', 'id="Shape_0abc123"').replace('id="Activity_Book_di"', 'id="Event_Archive_di"'); + const xml = (await drawnDefault()).replace('id="task_check_di"', 'id="Shape_0abc123"').replace('id="task_book_di"', 'id="endEvent_archive_di"'); const result = await applyToXml(xml, [ - { op: 'set', id: 'Activity_Check', values: { id: 'Activity_Audit' } }, - { op: 'set', id: 'Event_Done', values: { id: 'Event_Archive' } }, + { op: 'set', id: 'task_check', values: { id: 'task_audit' } }, + { op: 'set', id: 'endEvent_done', values: { id: 'endEvent_archive' } }, ]); const di = diOf(result.xml); - expect(di.get('Activity_Audit')).toBe('Shape_0abc123'); - expect(di.get('Event_Archive')).toBe('Event_Archive_di_2'); - expect(di.get('Activity_Book')).toBe('Event_Archive_di'); + expect(di.get('task_audit')).toBe('Shape_0abc123'); + expect(di.get('endEvent_archive')).toBe('endEvent_archive_di_2'); + expect(di.get('task_book')).toBe('endEvent_archive_di'); }); it('a derived DI id with a collision suffix (_2) follows too', async () => { - const xml = (await drawnDefault('bpmn')).replace('id="BPMNShape_Activity_Book"', 'id="BPMNShape_Activity_Book_2"').replace('id="BPMNEdge_Flow_CheckToOk"', 'id="BPMNEdge_Flow_CheckToOk_3"'); + const xml = (await drawnDefault('bpmn')).replace('id="BPMNShape_task_book"', 'id="BPMNShape_task_book_2"').replace('id="BPMNEdge_flow_checkToOk"', 'id="BPMNEdge_flow_checkToOk_3"'); const result = await applyToXml(xml, RENAMES); - expect(diOf(result.xml).get('Activity_Audit')).toBe('BPMNShape_Activity_Audit'); - expect(diOf(result.xml).get('Flow_CheckToAudit')).toBe('BPMNEdge_Flow_CheckToAudit'); - const book = await applyToXml(xml, [{ op: 'set', id: 'Activity_Book', values: { id: 'Activity_Post' } }]); - expect(diOf(book.xml).get('Activity_Post')).toBe('BPMNShape_Activity_Post'); + expect(diOf(result.xml).get('task_audit')).toBe('BPMNShape_task_audit'); + expect(diOf(result.xml).get('flow_checkToAudit')).toBe('BPMNEdge_flow_checkToAudit'); + const book = await applyToXml(xml, [{ op: 'set', id: 'task_book', values: { id: 'task_post' } }]); + expect(diOf(book.xml).get('task_post')).toBe('BPMNShape_task_post'); }); it('in a batch, a DI id of an element removed before is free for the rename, as in two commands', async () => { const ops: EditCommand[] = [ - { op: 'remove', ids: ['Activity_Book'] }, - { op: 'set', id: 'Activity_Check', values: { id: 'Activity_Book' } }, + { op: 'remove', ids: ['task_book'] }, + { op: 'set', id: 'task_check', values: { id: 'task_book' } }, ]; for (const style of ['suffix', 'bpmn'] as const) { for (const layout of [undefined, false] as const) { @@ -923,31 +854,31 @@ describe('`set id=` takes a DI id derived from the old id along', () => { const batch = await applyToXml(await drawnDefault(style), ops, opts); const first = await applyToXml(await drawnDefault(style), [ops[0]!], opts); const second = await applyToXml(first.xml, [ops[1]!], opts); - expect(diOf(batch.xml).get('Activity_Book')).toBe(style === 'suffix' ? 'Activity_Book_di' : 'BPMNShape_Activity_Book'); + expect(diOf(batch.xml).get('task_book')).toBe(style === 'suffix' ? 'task_book_di' : 'BPMNShape_task_book'); expect(batch.xml).toBe(second.xml); } } }); it('the renamed element\'s DI element is written anew: a comment inside it is dropped, and the result says so', async () => { - const xml = (await drawnDefault()).replace(/()/, '$1\n '); + const xml = (await drawnDefault()).replace(/()/, '$1\n '); expect(xml).toContain(''); for (const layout of [undefined, false] as const) { - const result = await applyToXml(xml, [{ op: 'set', id: 'Activity_Check', values: { id: 'Activity_Audit' } }], layout === false ? { layout } : {}); - expect(diOf(result.xml).get('Activity_Audit')).toBe('Activity_Audit_di'); + const result = await applyToXml(xml, [{ op: 'set', id: 'task_check', values: { id: 'task_audit' } }], layout === false ? { layout } : {}); + expect(diOf(result.xml).get('task_audit')).toBe('task_audit_di'); expect(result.xml).not.toContain(''); expect(result.result.notes).toContain('1 XML comment(s) dropped: they were inside or next to elements the change removed or rewrote'); } }); it('following W_FLOW_ID_STALE with its `bpmn set` gives the file the default rename gives', async () => { - const splice: EditCommand[] = [{ op: 'add', kind: 'task', name: 'Review', flow: 'Flow_OkToBook' }]; + const splice: EditCommand[] = [{ op: 'add', kind: 'task', name: 'Review', flow: 'flow_okToBook' }]; for (const style of ['suffix', 'bpmn'] as const) { const renamed = await applyToXml(await drawnDefault(style), splice); const keptIds = await applyToXml(await drawnDefault(style), splice, { keepFlowIds: true }); const command = /`bpmn set (\S+) id=(\S+)`/.exec(stale(keptIds.result.warnings.added)[0]!.hint!)!; const followed = await applyToXml(keptIds.xml, [{ op: 'set', id: command[1]!, values: { id: command[2]! } }], { keepFlowIds: true }); - expect(diOf(followed.xml).get('Flow_OkToReview')).toBe(style === 'suffix' ? 'Flow_OkToReview_di' : 'BPMNEdge_Flow_OkToReview'); + expect(diOf(followed.xml).get('flow_okToReview')).toBe(style === 'suffix' ? 'flow_okToReview_di' : 'BPMNEdge_flow_okToReview'); expect(followed.xml).toBe(renamed.xml); } }); @@ -959,18 +890,18 @@ describe('--no-layout with ids kept: a flow whose ends changed loses its stale e it('a move: every flow whose ends changed loses its edge and the note names them, like in the default mode; the next write draws them at their new ends', async () => { const drawn = (await layoutXml(LINE)).xml; - expect(drawnFlows(drawn)).toEqual(['Flow_AToB', 'Flow_BToC', 'Flow_CToDone', 'Flow_StartToA']); - const result = await applyToXml(drawn, [{ op: 'move', ids: ['Activity_B'], after: 'Activity_C' }], { keepFlowIds: true, layout: false }); - expect(drawnFlows(result.xml)).toEqual(['Flow_StartToA']); - expect(result.result.notes.filter((note) => note.startsWith('removed'))).toEqual(['removed 3 stale diagram element(s), among them the edge(s) of Flow_AToB, Flow_BToC, Flow_CToDone (their ends changed; the next model edit without --no-layout draws them, or `bpmn route `)']); - expect(result.result.layout?.metrics?.added.filter((problem) => problem.kind === 'missing').map((problem) => problem.ids[0]).sort()).toEqual(['Flow_AToB', 'Flow_BToC', 'Flow_CToDone']); + expect(drawnFlows(drawn)).toEqual(['flow_aToB', 'flow_bToC', 'flow_cToDone', 'flow_startToA']); + const result = await applyToXml(drawn, [{ op: 'move', ids: ['task_b'], after: 'task_c' }], { keepFlowIds: true, layout: false }); + expect(drawnFlows(result.xml)).toEqual(['flow_startToA']); + expect(result.result.notes.filter((note) => note.startsWith('removed'))).toEqual(['removed 3 stale diagram element(s), among them the edge(s) of flow_aToB, flow_bToC, flow_cToDone (their ends changed; the next model edit without --no-layout draws them, or `bpmn route `)']); + expect(result.result.layout?.metrics?.added.filter((problem) => problem.kind === 'missing').map((problem) => problem.ids[0]).sort()).toEqual(['flow_aToB', 'flow_bToC', 'flow_cToDone']); // the default mode: the bridge and the flow into B lose theirs, the removed flow's edge goes with it - const followed = await applyToXml(drawn, [{ op: 'move', ids: ['Activity_B'], after: 'Activity_C' }], { layout: false }); - expect(drawnFlows(followed.xml)).toEqual(['Flow_StartToA']); - expect(followed.result.notes.filter((note) => note.startsWith('removed'))).toEqual(['removed 3 stale diagram element(s), among them the edge(s) of Flow_AToC, Flow_CToB (their ends changed; the next model edit without --no-layout draws them, or `bpmn route `)']); - const next = await applyToXml(result.xml, [{ op: 'set', id: 'Activity_A', values: { name: 'A2' } }], { keepFlowIds: true }); + const followed = await applyToXml(drawn, [{ op: 'move', ids: ['task_b'], after: 'task_c' }], { layout: false }); + expect(drawnFlows(followed.xml)).toEqual(['flow_startToA']); + expect(followed.result.notes.filter((note) => note.startsWith('removed'))).toEqual(['removed 3 stale diagram element(s), among them the edge(s) of flow_aToC, flow_cToB (their ends changed; the next model edit without --no-layout draws them, or `bpmn route `)']); + const next = await applyToXml(result.xml, [{ op: 'set', id: 'task_a', values: { name: 'A2' } }], { keepFlowIds: true }); expect(next.result.layout?.mode).toBe('incremental'); - expect([...(next.result.layout?.placed ?? [])].sort()).toEqual(['Flow_AToB', 'Flow_BToC', 'Flow_CToDone']); + expect([...(next.result.layout?.placed ?? [])].sort()).toEqual(['flow_aToB', 'flow_bToC', 'flow_cToDone']); expect(next.result.layout?.metrics?.after.counts.missing).toBe(0); }); }); diff --git a/test/domain/editing/lane-splice.test.ts b/test/domain/editing/lane-splice.test.ts index 2ff12555..af2fb7fb 100644 --- a/test/domain/editing/lane-splice.test.ts +++ b/test/domain/editing/lane-splice.test.ts @@ -28,9 +28,9 @@ const inside = (inner: Box, outer: Box): boolean => inner.y >= outer.y && inner. async function claims(): Promise { const result = await applyToXml((await newXml({ processName: 'Claims' })).xml, [ { op: 'add', kind: 'participant', name: 'Claims' }, - { op: 'add', kind: 'lane', name: 'Clerk', in: 'Participant_Claims', as: '$clerk' }, - { op: 'add', kind: 'lane', name: 'Accounting', in: 'Participant_Claims', as: '$acc' }, - { op: 'add', kind: 'start', name: 'Claim received', lane: '$clerk', in: 'Process_Claims', as: '$s' }, + { op: 'add', kind: 'lane', name: 'Clerk', in: 'participant_claims', as: '$clerk' }, + { op: 'add', kind: 'lane', name: 'Accounting', in: 'participant_claims', as: '$acc' }, + { op: 'add', kind: 'start', name: 'Claim received', lane: '$clerk', in: 'claims', as: '$s' }, { op: 'add', kind: 'userTask', name: 'Check claim', after: '$s', as: '$check' }, { op: 'add', kind: 'exclusiveGateway', name: 'Goodwill?', after: '$check', as: '$g' }, { op: 'add', kind: 'userTask', name: 'Reject', after: '$g', flowName: 'no', as: '$reject' }, @@ -45,48 +45,48 @@ async function claims(): Promise { describe('a node added into a flow between two lanes (audit #14)', () => { it('after a branching node: the target lane, on the target row; no lane grows', async () => { const xml = await claims(); - const before = { clerk: bounds(xml, 'Lane_Clerk'), acc: bounds(xml, 'Lane_Accounting') }; - const result = await applyToXml(xml, [{ op: 'add', kind: 'exclusiveGateway', name: 'Amount > 500?', flow: 'Flow_GoodwillToRefund' }], { layout: 'incremental' }); + const before = { clerk: bounds(xml, 'lane_clerk'), acc: bounds(xml, 'lane_accounting') }; + const result = await applyToXml(xml, [{ op: 'add', kind: 'exclusiveGateway', name: 'Amount > 500?', flow: 'flow_goodwillToRefund' }], { layout: 'incremental' }); const doc = await Doc.fromXml(result.xml); - expect(laneOf(doc, doc.require('Gateway_Amount500'))?.get('id')).toBe('Lane_Accounting'); + expect(laneOf(doc, doc.require('gateway_amount500'))?.get('id')).toBe('lane_accounting'); const inherited = result.result.warnings.added.find((warning) => warning.code === 'W_LANE_INHERITED'); - expect(inherited?.message).toBe('Gateway_Amount500 is in Lane_Accounting (the lane of Activity_Refund, on whose row the layout puts it after the branching Gateway_Goodwill); the flow it went into runs from Gateway_Goodwill in Lane_Clerk to Activity_Refund in Lane_Accounting'); - expect(inherited?.hint).toBe('If Lane_Clerk does it: `bpmn move Gateway_Amount500 --lane Lane_Clerk` (or --lane Lane_Clerk when adding).'); - const after = { clerk: bounds(result.xml, 'Lane_Clerk'), acc: bounds(result.xml, 'Lane_Accounting') }; - expect(inside(bounds(result.xml, 'Gateway_Amount500'), after.acc)).toBe(true); + expect(inherited?.message).toBe('gateway_amount500 is in lane_accounting (the lane of serviceTask_refund, on whose row the layout puts it after the branching gateway_goodwill); the flow it went into runs from gateway_goodwill in lane_clerk to serviceTask_refund in lane_accounting'); + expect(inherited?.hint).toBe('If lane_clerk does it: `bpmn move gateway_amount500 --lane lane_clerk` (or --lane lane_clerk when adding).'); + const after = { clerk: bounds(result.xml, 'lane_clerk'), acc: bounds(result.xml, 'lane_accounting') }; + expect(inside(bounds(result.xml, 'gateway_amount500'), after.acc)).toBe(true); expect([after.clerk.y, after.clerk.height, after.acc.height]).toEqual([before.clerk.y, before.clerk.height, before.acc.height]); // on the target's row - const gw = bounds(result.xml, 'Gateway_Amount500'); - const refund = bounds(result.xml, 'Activity_Refund'); + const gw = bounds(result.xml, 'gateway_amount500'); + const refund = bounds(result.xml, 'serviceTask_refund'); expect(gw.y + gw.height / 2).toBeCloseTo(refund.y + refund.height / 2, 0); expect(result.result.layout.metrics?.added ?? []).toEqual([]); }); it('after a plain node: its lane, on its row; --lane chooses the other one up front', async () => { const xml = await claims(); - const base = await applyToXml(xml, [{ op: 'add', kind: 'serviceTask', name: 'Pay', after: 'Activity_Approve', lane: 'Lane_Accounting' }]); - const result = await applyToXml(base.xml, [{ op: 'add', kind: 'userTask', name: 'Sign', flow: 'Flow_ApproveToPay' }], { layout: 'incremental' }); + const base = await applyToXml(xml, [{ op: 'add', kind: 'serviceTask', name: 'Pay', after: 'userTask_approve', lane: 'lane_accounting' }]); + const result = await applyToXml(base.xml, [{ op: 'add', kind: 'userTask', name: 'Sign', flow: 'flow_approveToPay' }], { layout: 'incremental' }); const doc = await Doc.fromXml(result.xml); - expect(laneOf(doc, doc.require('Activity_Sign'))?.get('id')).toBe('Lane_Clerk'); + expect(laneOf(doc, doc.require('userTask_sign'))?.get('id')).toBe('lane_clerk'); expect(result.result.warnings.added.map((warning) => warning.code)).toContain('W_LANE_INHERITED'); - expect(inside(bounds(result.xml, 'Activity_Sign'), bounds(result.xml, 'Lane_Clerk'))).toBe(true); - const explicit = await applyToXml(base.xml, [{ op: 'add', kind: 'userTask', name: 'Sign', flow: 'Flow_ApproveToPay', lane: 'Lane_Accounting' }], { layout: 'incremental' }); + expect(inside(bounds(result.xml, 'userTask_sign'), bounds(result.xml, 'lane_clerk'))).toBe(true); + const explicit = await applyToXml(base.xml, [{ op: 'add', kind: 'userTask', name: 'Sign', flow: 'flow_approveToPay', lane: 'lane_accounting' }], { layout: 'incremental' }); expect(explicit.result.warnings.added.map((warning) => warning.code)).not.toContain('W_LANE_INHERITED'); - expect(inside(bounds(explicit.xml, 'Activity_Sign'), bounds(explicit.xml, 'Lane_Accounting'))).toBe(true); + expect(inside(bounds(explicit.xml, 'userTask_sign'), bounds(explicit.xml, 'lane_accounting'))).toBe(true); }); it('a splice inside one lane says nothing; --before takes the anchor lane', async () => { const xml = await claims(); - const same = await applyToXml(xml, [{ op: 'add', kind: 'task', name: 'Log', flow: 'Flow_CheckClaimToGoodwill' }]); + const same = await applyToXml(xml, [{ op: 'add', kind: 'task', name: 'Log', flow: 'flow_checkClaimToGoodwill' }]); expect(same.result.warnings.added.map((warning) => warning.code)).not.toContain('W_LANE_INHERITED'); - const before = await applyToXml(xml, [{ op: 'add', kind: 'task', name: 'Prepare refund', before: 'Activity_Refund' }], { layout: 'incremental' }); + const before = await applyToXml(xml, [{ op: 'add', kind: 'task', name: 'Prepare refund', before: 'serviceTask_refund' }], { layout: 'incremental' }); const doc = await Doc.fromXml(before.xml); - expect(laneOf(doc, doc.require('Activity_PrepareRefund'))?.get('id')).toBe('Lane_Accounting'); - expect(inside(bounds(before.xml, 'Activity_PrepareRefund'), bounds(before.xml, 'Lane_Accounting'))).toBe(true); + expect(laneOf(doc, doc.require('task_prepareRefund'))?.get('id')).toBe('lane_accounting'); + expect(inside(bounds(before.xml, 'task_prepareRefund'), bounds(before.xml, 'lane_accounting'))).toBe(true); // before a node whose predecessor does not branch: the anchor's lane, and the message says so - const plain = await applyToXml(xml, [{ op: 'add', kind: 'serviceTask', name: 'Pay', after: 'Activity_Approve', lane: 'Lane_Accounting' }]); - const result = await applyToXml(plain.xml, [{ op: 'add', kind: 'task', name: 'Prepare', before: 'Activity_Pay' }], { layout: 'incremental' }); - expect(result.result.warnings.added.find((warning) => warning.code === 'W_LANE_INHERITED')?.message).toBe('Activity_Prepare is in Lane_Accounting (the lane of Activity_Pay); the flow it went into runs from Activity_Approve in Lane_Clerk to Activity_Pay in Lane_Accounting'); - expect(inside(bounds(result.xml, 'Activity_Prepare'), bounds(result.xml, 'Lane_Accounting'))).toBe(true); + const plain = await applyToXml(xml, [{ op: 'add', kind: 'serviceTask', name: 'Pay', after: 'userTask_approve', lane: 'lane_accounting' }]); + const result = await applyToXml(plain.xml, [{ op: 'add', kind: 'task', name: 'Prepare', before: 'serviceTask_pay' }], { layout: 'incremental' }); + expect(result.result.warnings.added.find((warning) => warning.code === 'W_LANE_INHERITED')?.message).toBe('task_prepare is in lane_accounting (the lane of serviceTask_pay); the flow it went into runs from userTask_approve in lane_clerk to serviceTask_pay in lane_accounting'); + expect(inside(bounds(result.xml, 'task_prepare'), bounds(result.xml, 'lane_accounting'))).toBe(true); }); }); diff --git a/test/domain/editing/move-lanes.test.ts b/test/domain/editing/move-lanes.test.ts index f1971e81..899ce81c 100644 --- a/test/domain/editing/move-lanes.test.ts +++ b/test/domain/editing/move-lanes.test.ts @@ -43,10 +43,10 @@ const inside = (inner: Box, outer: Box): boolean => inner.y >= outer.y && inner. async function claims(reminder = false): Promise { const result = await applyToXml((await newXml({ processName: 'Claims' })).xml, [ { op: 'add', kind: 'participant', name: 'Claims' }, - { op: 'add', kind: 'lane', name: 'Clerk', in: 'Participant_Claims', as: '$clerk' }, - { op: 'add', kind: 'lane', name: 'Accounting', in: 'Participant_Claims', as: '$acc' }, - { op: 'add', kind: 'lane', name: 'Archive', in: 'Participant_Claims', as: '$arc' }, - { op: 'add', kind: 'start', name: 'Claim received', lane: '$clerk', in: 'Process_Claims', as: '$s' }, + { op: 'add', kind: 'lane', name: 'Clerk', in: 'participant_claims', as: '$clerk' }, + { op: 'add', kind: 'lane', name: 'Accounting', in: 'participant_claims', as: '$acc' }, + { op: 'add', kind: 'lane', name: 'Archive', in: 'participant_claims', as: '$arc' }, + { op: 'add', kind: 'start', name: 'Claim received', lane: '$clerk', in: 'claims', as: '$s' }, { op: 'add', kind: 'userTask', name: 'Check claim', after: '$s', as: '$check' }, { op: 'add', kind: 'subProcess', name: 'Prepare', after: '$check', as: '$prep' }, { op: 'add', kind: 'startEvent', name: 'Prepare started', in: '$prep', as: '$ps' }, @@ -58,7 +58,7 @@ async function claims(reminder = false): Promise { { op: 'add', kind: 'serviceTask', name: 'Refund', after: '$g', flowName: 'yes', lane: '$acc', as: '$refund' }, { op: 'add', kind: 'serviceTask', name: 'Book refund', after: '$refund', lane: '$arc', as: '$book' }, { op: 'add', kind: 'end', name: 'Refunded', after: '$book' }, - { op: 'add', kind: 'task', name: 'Note', lane: '$arc', in: 'Process_Claims' }, + { op: 'add', kind: 'task', name: 'Note', lane: '$arc', in: 'claims' }, ...(reminder ? [ { op: 'add', kind: 'boundaryEvent:timer', name: 'Late', on: '$reject', timer: 'P1D', nonInterrupting: true, as: '$late' }, @@ -78,73 +78,73 @@ const laneIdOf = async (xml: string, id: string): Promise => describe('move into a flow between two lanes (audit #14 for move)', () => { it('a node out of a sub-process into the flow after a branching gateway: the target lane, on its row, W_LANE_INHERITED', async () => { const xml = await claims(); - const lanes = { clerk: bounds(xml, 'Lane_Clerk'), acc: bounds(xml, 'Lane_Accounting') }; - const result = await applyToXml(xml, [{ op: 'move', ids: ['Activity_CollectDocuments'], flow: 'Flow_GoodwillToRefund' }], { layout: 'incremental' }); - expect(await laneIdOf(result.xml, 'Activity_CollectDocuments')).toBe('Lane_Accounting'); + const lanes = { clerk: bounds(xml, 'lane_clerk'), acc: bounds(xml, 'lane_accounting') }; + const result = await applyToXml(xml, [{ op: 'move', ids: ['task_collectDocuments'], flow: 'flow_goodwillToRefund' }], { layout: 'incremental' }); + expect(await laneIdOf(result.xml, 'task_collectDocuments')).toBe('lane_accounting'); const inherited = result.result.warnings.added.find((warning) => warning.code === 'W_LANE_INHERITED'); expect(inherited?.message).toBe( - 'Activity_CollectDocuments is in Lane_Accounting (the lane of Activity_Refund, on whose row the layout puts it after the branching Gateway_Goodwill); the flow it went into runs from Gateway_Goodwill in Lane_Clerk to Activity_Refund in Lane_Accounting', + 'task_collectDocuments is in lane_accounting (the lane of serviceTask_refund, on whose row the layout puts it after the branching gateway_goodwill); the flow it went into runs from gateway_goodwill in lane_clerk to serviceTask_refund in lane_accounting', ); - expect(inherited?.hint).toBe('If Lane_Clerk does it: `bpmn move Activity_CollectDocuments --lane Lane_Clerk` (or --lane Lane_Clerk when adding).'); - expect(inside(bounds(result.xml, 'Activity_CollectDocuments'), bounds(result.xml, 'Lane_Accounting'))).toBe(true); + expect(inherited?.hint).toBe('If lane_clerk does it: `bpmn move task_collectDocuments --lane lane_clerk` (or --lane lane_clerk when adding).'); + expect(inside(bounds(result.xml, 'task_collectDocuments'), bounds(result.xml, 'lane_accounting'))).toBe(true); // on the target's row; neither lane grew - const moved = bounds(result.xml, 'Activity_CollectDocuments'); - const refund = bounds(result.xml, 'Activity_Refund'); + const moved = bounds(result.xml, 'task_collectDocuments'); + const refund = bounds(result.xml, 'serviceTask_refund'); expect(moved.y + moved.height / 2).toBeCloseTo(refund.y + refund.height / 2, 0); - expect([bounds(result.xml, 'Lane_Clerk').height, bounds(result.xml, 'Lane_Accounting').height]).toEqual([lanes.clerk.height, lanes.acc.height]); + expect([bounds(result.xml, 'lane_clerk').height, bounds(result.xml, 'lane_accounting').height]).toEqual([lanes.clerk.height, lanes.acc.height]); expect((result.result.layout.metrics?.added ?? []).filter((problem) => problem.kind.startsWith('outside'))).toEqual([]); }); it('--after a plain node whose flow crosses lanes: the anchor lane, W_LANE_INHERITED; --lane decides up front', async () => { const xml = await claims(); - const result = await applyToXml(xml, [{ op: 'move', ids: ['Activity_CollectDocuments'], after: 'Activity_Refund' }], { layout: 'incremental' }); - expect(await laneIdOf(result.xml, 'Activity_CollectDocuments')).toBe('Lane_Accounting'); + const result = await applyToXml(xml, [{ op: 'move', ids: ['task_collectDocuments'], after: 'serviceTask_refund' }], { layout: 'incremental' }); + expect(await laneIdOf(result.xml, 'task_collectDocuments')).toBe('lane_accounting'); expect(result.result.warnings.added.find((warning) => warning.code === 'W_LANE_INHERITED')?.message).toBe( - 'Activity_CollectDocuments is in Lane_Accounting (the lane of Activity_Refund); the flow it went into runs from Activity_Refund in Lane_Accounting to Activity_BookRefund in Lane_Archive', + 'task_collectDocuments is in lane_accounting (the lane of serviceTask_refund); the flow it went into runs from serviceTask_refund in lane_accounting to serviceTask_bookRefund in lane_archive', ); - const explicit = await applyToXml(xml, [{ op: 'move', ids: ['Activity_CollectDocuments'], after: 'Activity_Refund', lane: 'Lane_Archive' }], { layout: 'incremental' }); - expect(await laneIdOf(explicit.xml, 'Activity_CollectDocuments')).toBe('Lane_Archive'); + const explicit = await applyToXml(xml, [{ op: 'move', ids: ['task_collectDocuments'], after: 'serviceTask_refund', lane: 'lane_archive' }], { layout: 'incremental' }); + expect(await laneIdOf(explicit.xml, 'task_collectDocuments')).toBe('lane_archive'); expect(explicit.result.warnings.added.map((warning) => warning.code)).not.toContain('W_LANE_INHERITED'); - expect(inside(bounds(explicit.xml, 'Activity_CollectDocuments'), bounds(explicit.xml, 'Lane_Archive'))).toBe(true); + expect(inside(bounds(explicit.xml, 'task_collectDocuments'), bounds(explicit.xml, 'lane_archive'))).toBe(true); }); it('a node in a lane keeps it: a note when the flow runs between two other lanes, nothing when its lane is one of them', async () => { const xml = await claims(); - const result = await applyToXml(xml, [{ op: 'move', ids: ['Activity_Note'], flow: 'Flow_GoodwillToRefund' }], { layout: 'incremental' }); - expect(await laneIdOf(result.xml, 'Activity_Note')).toBe('Lane_Archive'); + const result = await applyToXml(xml, [{ op: 'move', ids: ['task_note'], flow: 'flow_goodwillToRefund' }], { layout: 'incremental' }); + expect(await laneIdOf(result.xml, 'task_note')).toBe('lane_archive'); expect(result.result.warnings.added.map((warning) => warning.code)).not.toContain('W_LANE_INHERITED'); expect(result.result.notes).toContain( - 'Activity_Note stays in its lane Lane_Archive; the flow it went into runs from Gateway_Goodwill in Lane_Clerk to Activity_Refund in Lane_Accounting (`bpmn move Activity_Note --lane Lane_Accounting` puts it on the row the layout gives a node there)', + 'task_note stays in its lane lane_archive; the flow it went into runs from gateway_goodwill in lane_clerk to serviceTask_refund in lane_accounting (`bpmn move task_note --lane lane_accounting` puts it on the row the layout gives a node there)', ); - expect(inside(bounds(result.xml, 'Activity_Note'), bounds(result.xml, 'Lane_Archive'))).toBe(true); - const one = await applyToXml(xml, [{ op: 'move', ids: ['Activity_Note'], flow: 'Flow_RefundToBookRefund' }], { layout: 'incremental' }); - expect(await laneIdOf(one.xml, 'Activity_Note')).toBe('Lane_Archive'); + expect(inside(bounds(result.xml, 'task_note'), bounds(result.xml, 'lane_archive'))).toBe(true); + const one = await applyToXml(xml, [{ op: 'move', ids: ['task_note'], flow: 'flow_refundToBookRefund' }], { layout: 'incremental' }); + expect(await laneIdOf(one.xml, 'task_note')).toBe('lane_archive'); expect(one.result.notes.filter((note) => note.includes('stays in its lane'))).toEqual([]); }); it('a node that keeps the branching anchor\'s lane goes on a row of that lane: the lane never stretches over to the target\'s row (move and add --lane)', async () => { // the reminder path keeps a row in Clerk below Goodwill?'s, free in the column the node goes to const xml = await claims(true); - const before = { clerk: bounds(xml, 'Lane_Clerk'), acc: bounds(xml, 'Lane_Accounting') }; - const moved = await applyToXml(xml, [{ op: 'move', ids: ['Activity_CheckClaim'], flow: 'Flow_GoodwillToRefund' }], { layout: 'incremental' }); - const added = await applyToXml(xml, [{ op: 'add', kind: 'task', id: 'Activity_Log', name: 'Log', flow: 'Flow_GoodwillToRefund', lane: 'Lane_Clerk' }], { layout: 'incremental' }); + const before = { clerk: bounds(xml, 'lane_clerk'), acc: bounds(xml, 'lane_accounting') }; + const moved = await applyToXml(xml, [{ op: 'move', ids: ['userTask_checkClaim'], flow: 'flow_goodwillToRefund' }], { layout: 'incremental' }); + const added = await applyToXml(xml, [{ op: 'add', kind: 'task', id: 'Activity_Log', name: 'Log', flow: 'flow_goodwillToRefund', lane: 'lane_clerk' }], { layout: 'incremental' }); for (const [result, id] of [ - [moved, 'Activity_CheckClaim'], + [moved, 'userTask_checkClaim'], [added, 'Activity_Log'], ] as const) { - expect(await laneIdOf(result.xml, id)).toBe('Lane_Clerk'); - expect(inside(bounds(result.xml, id), bounds(result.xml, 'Lane_Clerk'))).toBe(true); - // before: drawn on Activity_Refund's row, Lane_Clerk stretched down over it and pushed Lane_Accounting away - expect(bounds(result.xml, 'Lane_Clerk').height).toBe(before.clerk.height); - expect(bounds(result.xml, 'Lane_Accounting').y).toBe(before.acc.y); + expect(await laneIdOf(result.xml, id)).toBe('lane_clerk'); + expect(inside(bounds(result.xml, id), bounds(result.xml, 'lane_clerk'))).toBe(true); + // before: drawn on serviceTask_refund's row, lane_clerk stretched down over it and pushed lane_accounting away + expect(bounds(result.xml, 'lane_clerk').height).toBe(before.clerk.height); + expect(bounds(result.xml, 'lane_accounting').y).toBe(before.acc.y); expect((result.result.layout.metrics?.added ?? []).filter((problem) => problem.kind.startsWith('outside') || problem.kind === 'overlaps')).toEqual([]); } }); it('a flow inside one lane: the lane of the anchor, no warning', async () => { const xml = await claims(); - const result = await applyToXml(xml, [{ op: 'move', ids: ['Activity_CollectDocuments'], flow: 'Flow_GoodwillToReject' }], { layout: 'incremental' }); - expect(await laneIdOf(result.xml, 'Activity_CollectDocuments')).toBe('Lane_Clerk'); + const result = await applyToXml(xml, [{ op: 'move', ids: ['task_collectDocuments'], flow: 'flow_goodwillToReject' }], { layout: 'incremental' }); + expect(await laneIdOf(result.xml, 'task_collectDocuments')).toBe('lane_clerk'); expect(result.result.warnings.added.map((warning) => warning.code)).not.toContain('W_LANE_INHERITED'); }); }); diff --git a/test/domain/editing/ops-create.test.ts b/test/domain/editing/ops-create.test.ts index 860ca347..527222e1 100644 --- a/test/domain/editing/ops-create.test.ts +++ b/test/domain/editing/ops-create.test.ts @@ -86,7 +86,6 @@ const WITH_JOIN = definitionsXml(` `); -// mostly prefixed ids: its task Task_A makes new tasks Task_* (the file's id style, src/domain/document/ids/IdStyle.ts) const WITH_LANES = definitionsXml(` StartTask_A @@ -106,11 +105,11 @@ describe('add: placement modes', () => { it('after a node with one outgoing flow splices into it', async () => { const doc = await linearDoc(); const cs = add(doc, { kind: 'userTask', name: 'Do B', after: 'Task_A' }); - expect(cs.created[0]).toMatchObject({ id: 'Activity_DoB', kind: 'userTask', name: 'Do B', detail: 'after Task_A' }); - expect(edge(doc, 'F2')).toBe('Task_A->Activity_DoB'); + expect(cs.created[0]).toMatchObject({ id: 'userTask_doB', kind: 'userTask', name: 'Do B', detail: 'after Task_A' }); + expect(edge(doc, 'F2')).toBe('Task_A->userTask_doB'); const second = cs.created.find((change) => change.kind === 'sequenceFlow')!; - expect(edge(doc, second.id)).toBe('Activity_DoB->End'); - expect(flowIds(doc)).toEqual(['Start', 'Task_A', 'End', 'F1', 'F2', 'Activity_DoB', second.id]); + expect(edge(doc, second.id)).toBe('userTask_doB->End'); + expect(flowIds(doc)).toEqual(['Start', 'Task_A', 'End', 'F1', 'F2', 'userTask_doB', second.id]); expect(cs.notes.some((note) => note.includes('inserted between Task_A and End'))).toBe(true); await roundTrip(doc); }); @@ -119,15 +118,15 @@ describe('add: placement modes', () => { const doc = await docFromXml(WITH_GATEWAY); const cs = add(doc, { kind: 'serviceTask', name: 'Reject', after: 'GW', flowName: 'no', default: true }); const flow = cs.created.find((change) => change.kind === 'sequenceFlow')!; - expect(edge(doc, flow.id)).toBe('GW->Activity_Reject'); + expect(edge(doc, flow.id)).toBe('GW->serviceTask_reject'); expect(doc.require('GW').get('default').get('id')).toBe(flow.id); expect(ids(doc.outgoing(doc.require('GW')))).toEqual(['F2', flow.id]); // declaration order: new node and flow right after the gateway's last flow - expect(flowIds(doc).indexOf('Activity_Reject')).toBe(flowIds(doc).indexOf('F2') + 1); + expect(flowIds(doc).indexOf('serviceTask_reject')).toBe(flowIds(doc).indexOf('F2') + 1); const cs2 = add(doc, { kind: 'task', name: 'After loose', after: 'Loose' }); const f2 = cs2.created.find((change) => change.kind === 'sequenceFlow')!; - expect(edge(doc, f2.id)).toBe('Loose->Activity_AfterLoose'); + expect(edge(doc, f2.id)).toBe('Loose->task_afterLoose'); await roundTrip(doc); }); @@ -150,11 +149,11 @@ describe('add: placement modes', () => { const doc = await linearDoc(); const cs = add(doc, { kind: 'task', name: 'Prep', before: 'Task_A' }); expect(cs.created[0]!.detail).toBe('before Task_A'); - expect(edge(doc, 'F1')).toBe('Start->Activity_Prep'); - expect(ids(doc.outgoing(doc.require('Activity_Prep')))).toHaveLength(1); - expect(edge(doc, ids(doc.outgoing(doc.require('Activity_Prep')))[0]!)).toBe('Activity_Prep->Task_A'); + expect(edge(doc, 'F1')).toBe('Start->task_prep'); + expect(ids(doc.outgoing(doc.require('task_prep')))).toHaveLength(1); + expect(edge(doc, ids(doc.outgoing(doc.require('task_prep')))[0]!)).toBe('task_prep->Task_A'); // declared right after its (spliced) incoming flow F1 - expect(flowIds(doc).indexOf('Activity_Prep')).toBe(flowIds(doc).indexOf('F1') + 1); + expect(flowIds(doc).indexOf('task_prep')).toBe(flowIds(doc).indexOf('F1') + 1); await roundTrip(doc); }); @@ -162,11 +161,11 @@ describe('add: placement modes', () => { const doc = await docFromXml(WITH_JOIN); const cs = add(doc, { kind: 'task', name: 'Third', before: 'Join' }); const flow = cs.created.find((change) => change.kind === 'sequenceFlow')!; - expect(edge(doc, flow.id)).toBe('Activity_Third->Join'); + expect(edge(doc, flow.id)).toBe('task_third->Join'); expect(ids(doc.incoming(doc.require('Join')))).toEqual(['F3', 'F4', flow.id]); expect(edge(doc, 'F3')).toBe('A->Join'); const cs2 = add(doc, { kind: 'task', name: 'Pre loose', before: 'Loose' }); - expect(edge(doc, cs2.created.find((change) => change.kind === 'sequenceFlow')!.id)).toBe('Activity_PreLoose->Loose'); + expect(edge(doc, cs2.created.find((change) => change.kind === 'sequenceFlow')!.id)).toBe('task_preLoose->Loose'); await roundTrip(doc); }); @@ -179,7 +178,7 @@ describe('add: placement modes', () => { const doc = await linearDoc(); const cs = add(doc, { kind: 'scriptTask', name: 'Mid', after: 'Start', before: 'Task_A' }); expect(cs.created[0]!.detail).toBe('between Start and Task_A'); - expect(edge(doc, 'F1')).toBe('Start->Activity_Mid'); + expect(edge(doc, 'F1')).toBe('Start->scriptTask_mid'); expect(codeOf(() => add(doc, { kind: 'task', name: 'No', after: 'Start', before: 'End' }))).toBe('E_NO_FLOW'); await roundTrip(doc); }); @@ -187,7 +186,7 @@ describe('add: placement modes', () => { it('flow splices into a given sequence flow', async () => { const doc = await linearDoc(); const cs = add(doc, { kind: 'manualTask', name: 'Check', flow: 'F2', condition: '${ok}' }); - expect(edge(doc, 'F2')).toBe('Task_A->Activity_Check'); + expect(edge(doc, 'F2')).toBe('Task_A->manualTask_check'); expect(doc.require('F2').get('conditionExpression').get('body')).toBe('${ok}'); expect(cs.created[0]!.detail).toBe('in flow F2'); await roundTrip(doc); @@ -196,12 +195,12 @@ describe('add: placement modes', () => { it('in places an unconnected node in a scope (process, sub-process, participant)', async () => { const doc = await linearDoc(); add(doc, { kind: 'subProcess', name: 'Sub', after: 'Task_A' }); - const cs = add(doc, { kind: 'startEvent', name: 'Inner start', in: 'Activity_Sub' }); - expect(cs.created[0]!.detail).toBe('in Activity_Sub'); - expect(ids(doc.flowElements(doc.require('Activity_Sub')))).toEqual(['Event_InnerStart']); + const cs = add(doc, { kind: 'startEvent', name: 'Inner start', in: 'subProcess_sub' }); + expect(cs.created[0]!.detail).toBe('in subProcess_sub'); + expect(ids(doc.flowElements(doc.require('subProcess_sub')))).toEqual(['startEvent_innerStart']); const cs2 = add(doc, { kind: 'task', name: 'Orphan' }); expect(cs2.created[0]!.detail).toBe('in Process_1'); - expect(doc.incoming(doc.require('Activity_Orphan'))).toHaveLength(0); + expect(doc.incoming(doc.require('task_orphan'))).toHaveLength(0); await roundTrip(doc); }); @@ -216,15 +215,15 @@ describe('add: placement modes', () => { it('on attaches boundary events (inheriting the lane of the host)', async () => { const doc = await docFromXml(WITH_LANES); const cs = add(doc, { kind: 'boundaryEvent:timer', name: 'Too late', on: 'Task_A', timer: 'PT2D' }); - const be = doc.require('Event_TooLate'); - expect(cs.created[0]).toMatchObject({ id: 'Event_TooLate', kind: 'boundaryEvent:timer', detail: 'on Task_A' }); + const be = doc.require('event_tooLate'); + expect(cs.created[0]).toMatchObject({ id: 'event_tooLate', kind: 'boundaryEvent:timer', detail: 'on Task_A' }); expect(be.get('attachedToRef').get('id')).toBe('Task_A'); - expect(ids(doc.boundaryEventsOf(doc.require('Task_A')))).toEqual(['Event_TooLate']); + expect(ids(doc.boundaryEventsOf(doc.require('Task_A')))).toEqual(['event_tooLate']); expect(laneOf(doc, be)?.get('id')).toBe('Lane_Sales'); - expect(flowIds(doc).indexOf('Event_TooLate')).toBe(flowIds(doc).indexOf('Task_A') + 1); - expect(cs.changed.some((change) => change.id === 'Event_TooLate' && change.detail === 'lane Lane_Sales')).toBe(true); + expect(flowIds(doc).indexOf('event_tooLate')).toBe(flowIds(doc).indexOf('Task_A') + 1); + expect(cs.changed.some((change) => change.id === 'event_tooLate' && change.detail === 'lane Lane_Sales')).toBe(true); const { xml } = await roundTrip(doc); - expect(xml).toMatch(//); + expect(xml).toMatch(//); expect(xml).toMatch(/PT2D<\/bpmn:timeDuration>/); }); @@ -237,11 +236,11 @@ describe('add: placement modes', () => { it('inherits the lane of the after/before anchor', async () => { const doc = await docFromXml(WITH_LANES); add(doc, { kind: 'task', name: 'Next', after: 'Task_A' }); - expect(laneOf(doc, doc.require('Task_Next'))?.get('id')).toBe('Lane_Sales'); + expect(laneOf(doc, doc.require('task_next'))?.get('id')).toBe('Lane_Sales'); add(doc, { kind: 'task', name: 'Last', before: 'End' }); - expect(laneOf(doc, doc.require('Task_Last'))?.get('id')).toBe('Lane_Backoffice'); - add(doc, { kind: 'task', name: 'Explicit', after: 'Task_Last', lane: 'Lane_Sales' }); - expect(laneOf(doc, doc.require('Task_Explicit'))?.get('id')).toBe('Lane_Sales'); + expect(laneOf(doc, doc.require('task_last'))?.get('id')).toBe('Lane_Backoffice'); + add(doc, { kind: 'task', name: 'Explicit', after: 'task_last', lane: 'Lane_Sales' }); + expect(laneOf(doc, doc.require('task_explicit'))?.get('id')).toBe('Lane_Sales'); await roundTrip(doc); }); }); @@ -251,18 +250,18 @@ describe('add: placement modes', () => { /* ------------------------------------------------------------------ */ describe('add: ids and options', () => { - it('uses Prefix_Slug ids, a word for the kind (and the placement) for unnamed nodes and warns on suffixes', async () => { + it('uses prefix_camelCase ids, a word for the kind (and the placement) for unnamed nodes and warns on suffixes', async () => { const doc = await linearDoc(); - expect(add(doc, { kind: 'task', name: 'Check invoice', in: 'Process_1' }).created[0]!.id).toBe('Activity_CheckInvoice'); + expect(add(doc, { kind: 'task', name: 'Check invoice', in: 'Process_1' }).created[0]!.id).toBe('task_checkInvoice'); const cs = add(doc, { kind: 'task', name: 'Check invoice', in: 'Process_1' }); - expect(cs.created[0]!.id).toBe('Activity_CheckInvoice_2'); + expect(cs.created[0]!.id).toBe('task_checkInvoice2'); expect(cs.warnings.map((warning) => warning.code)).toEqual(['W_ID_SUFFIXED']); - expect(add(doc, { kind: 'task', in: 'Process_1' }).created[0]!.id).toBe('Activity_Task'); - expect(add(doc, { kind: 'parallelGateway', after: 'Activity_CheckInvoice' }).created[0]!.id).toBe('Gateway_AfterCheckInvoice'); + expect(add(doc, { kind: 'task', in: 'Process_1' }).created[0]!.id).toBe('task_task'); + expect(add(doc, { kind: 'parallelGateway', after: 'task_checkInvoice' }).created[0]!.id).toBe('gateway_afterCheckInvoice'); // after an unnamed anchor placed after a named one: the nearest named anchor, once (never EndAfterAfterCheckInvoice) - expect(add(doc, { kind: 'endEvent', after: 'Gateway_AfterCheckInvoice' }).created[0]!.id).toBe('Event_EndAfterCheckInvoice'); - expect(add(doc, { kind: 'xor', name: 'Invoice ok?', in: 'Process_1' }).created[0]!.id).toBe('Gateway_InvoiceOk'); - expect(add(doc, { kind: 'bpmn:StartEvent', name: 'Order received', in: 'Process_1' }).created[0]!.id).toBe('Event_OrderReceived'); + expect(add(doc, { kind: 'endEvent', after: 'gateway_afterCheckInvoice' }).created[0]!.id).toBe('endEvent_afterCheckInvoiceGateway'); + expect(add(doc, { kind: 'xor', name: 'Invoice ok?', in: 'Process_1' }).created[0]!.id).toBe('gateway_invoiceOk'); + expect(add(doc, { kind: 'bpmn:StartEvent', name: 'Order received', in: 'Process_1' }).created[0]!.id).toBe('startEvent_orderReceived'); await roundTrip(doc); }); @@ -295,8 +294,8 @@ describe('add: ids and options', () => { it('adds documentation and records collapse requests for sub-processes', async () => { const doc = await linearDoc(); add(doc, { kind: 'subProcess', name: 'Details', after: 'Task_A', collapsed: true, doc: 'Explains it' }); - expect(doc.layoutRequests.expansion().collapse).toEqual(['Activity_Details']); - expect(many(doc.require('Activity_Details'), 'documentation')[0]!.get('text')).toBe('Explains it'); + expect(doc.layoutRequests.expansion().collapse).toEqual(['subProcess_details']); + expect(many(doc.require('subProcess_details'), 'documentation')[0]!.get('text')).toBe('Explains it'); const cs = add(doc, { kind: 'task', name: 'T', in: 'Process_1', collapsed: true }); expect(cs.warnings.map((warning) => warning.code)).toEqual(['W_OPTION_IGNORED']); const { xml } = await roundTrip(doc); @@ -307,9 +306,9 @@ describe('add: ids and options', () => { const doc = await linearDoc(); const cs = add(doc, { kind: 'eventSubProcess', name: 'Handle errors' }); expect(cs.created[0]!.kind).toBe('eventSubProcess'); - expect(doc.require('Activity_HandleErrors').get('triggeredByEvent')).toBe(true); + expect(doc.require('subProcess_handleErrors').get('triggeredByEvent')).toBe(true); const { xml } = await roundTrip(doc); - expect(xml).toMatch(/ { it('creates root messages by name and reuses them', async () => { const doc = await linearDoc(); const cs = add(doc, { kind: 'startEvent:message', name: 'Order received', message: 'Order received', in: 'Process_1' }); - expect(cs.created.map((change) => change.id)).toEqual(['Event_OrderReceived', 'Message_OrderReceived']); + expect(cs.created.map((change) => change.id)).toEqual(['startEvent_orderReceived', 'message_orderReceived']); expect(cs.created[1]).toMatchObject({ kind: 'message', name: 'Order received' }); add(doc, { kind: 'intermediateCatchEvent:message', name: 'Wait', message: 'Order received', after: 'Task_A' }); - add(doc, { kind: 'endEvent:message', name: 'Notify', message: 'Message_OrderReceived', in: 'Process_1' }); + add(doc, { kind: 'endEvent:message', name: 'Notify', message: 'message_orderReceived', in: 'Process_1' }); expect(doc.rootElementsOfType('bpmn:Message')).toHaveLength(1); - expect(new EventTrigger(doc.require('Event_Wait')).describe()).toBe('message Order received'); - expect(new EventTrigger(doc.require('Event_Notify')).details()).toEqual({ trigger: 'message', message: { id: 'Message_OrderReceived', name: 'Order received' } }); + expect(new EventTrigger(doc.require('event_wait')).describe()).toBe('message Order received'); + expect(new EventTrigger(doc.require('endEvent_notify')).details()).toEqual({ trigger: 'message', message: { id: 'message_orderReceived', name: 'Order received' } }); const { xml } = await roundTrip(doc); - expect(xml).toMatch(//); - expect(xml).toMatch(//); + expect(xml).toMatch(//); + expect(xml).toMatch(//); }); it('creates errors with codes, signals and escalations (reused by name)', async () => { @@ -338,24 +337,24 @@ describe('triggers', () => { add(doc, { kind: 'endEvent:error', name: 'Failed', error: 'PaymentFailed', errorCode: 'PAY-001', in: 'Process_1' }); add(doc, { kind: 'boundaryEvent:error', name: 'Catch', on: 'Task_A', error: 'PaymentFailed' }); expect(doc.rootElementsOfType('bpmn:Error')).toHaveLength(1); - expect(new EventTrigger(doc.require('Event_Catch')).describe()).toBe('error PaymentFailed (PAY-001)'); - expect(new EventTrigger(doc.require('Event_Catch')).details()).toEqual({ trigger: 'error', error: { id: 'Error_PaymentFailed', name: 'PaymentFailed', code: 'PAY-001' } }); + expect(new EventTrigger(doc.require('event_catch')).describe()).toBe('error PaymentFailed (PAY-001)'); + expect(new EventTrigger(doc.require('event_catch')).details()).toEqual({ trigger: 'error', error: { id: 'error_paymentFailed', name: 'PaymentFailed', code: 'PAY-001' } }); add(doc, { kind: 'endEvent:signal', name: 'Sig', signal: 'Done', in: 'Process_1' }); add(doc, { kind: 'startEvent:signal', name: 'Sig start', signal: 'Done', in: 'Process_1' }); - expect(doc.rootElementsOfType('bpmn:Signal').map((signal) => signal.get('id'))).toEqual(['Signal_Done']); - expect(new EventTrigger(doc.require('Event_SigStart')).describe()).toBe('signal Done'); + expect(doc.rootElementsOfType('bpmn:Signal').map((signal) => signal.get('id'))).toEqual(['signal_done']); + expect(new EventTrigger(doc.require('startEvent_sigStart')).describe()).toBe('signal Done'); add(doc, { kind: 'endEvent:escalation', name: 'Esc', escalation: 'Too slow', escalationCode: 'SLOW', in: 'Process_1' }); add(doc, { kind: 'boundaryEvent:escalation', name: 'Esc catch', on: 'Task_A', escalation: 'Too slow', nonInterrupting: true }); expect(doc.rootElementsOfType('bpmn:Escalation')).toHaveLength(1); - expect(new EventTrigger(doc.require('Event_EscCatch')).describe()).toBe('escalation Too slow (SLOW)'); - expect(new EventTrigger(doc.require('Event_EscCatch')).details()?.nonInterrupting).toBe(true); + expect(new EventTrigger(doc.require('event_escCatch')).describe()).toBe('escalation Too slow (SLOW)'); + expect(new EventTrigger(doc.require('event_escCatch')).details()?.nonInterrupting).toBe(true); const { xml } = await roundTrip(doc); - expect(xml).toMatch(//); - expect(xml).toMatch(//); - expect(xml).toMatch(//); + expect(xml).toMatch(//); + expect(xml).toMatch(//); + expect(xml).toMatch(//); }); it('classifies timers and accepts an override', async () => { @@ -365,12 +364,12 @@ describe('triggers', () => { add(doc, { kind: 'startEvent:timer', name: 'Date', timer: '2030-01-01T00:00:00Z', in: 'Process_1' }); add(doc, { kind: 'startEvent:timer', name: 'Cron', timer: '0 0 * * *', timerKind: 'cycle', in: 'Process_1' }); add(doc, { kind: 'boundaryEvent', name: 'Inferred', on: 'Task_A', timer: 'PT1H' }); - expect(new EventTrigger(doc.require('Event_Cycle')).details()?.timer).toEqual({ kind: 'cycle', value: 'R/PT1H' }); - expect(new EventTrigger(doc.require('Event_Duration')).details()?.timer).toEqual({ kind: 'duration', value: 'PT5M' }); - expect(new EventTrigger(doc.require('Event_Date')).details()?.timer).toEqual({ kind: 'date', value: '2030-01-01T00:00:00Z' }); - expect(new EventTrigger(doc.require('Event_Cron')).details()?.timer).toEqual({ kind: 'cycle', value: '0 0 * * *' }); - expect(new EventTrigger(doc.require('Event_Inferred')).describe()).toBe('PT1H'); - expect(new EventTrigger(doc.require('Event_Duration')).describe()).toBe('PT5M'); + expect(new EventTrigger(doc.require('startEvent_cycle')).details()?.timer).toEqual({ kind: 'cycle', value: 'R/PT1H' }); + expect(new EventTrigger(doc.require('event_duration')).details()?.timer).toEqual({ kind: 'duration', value: 'PT5M' }); + expect(new EventTrigger(doc.require('startEvent_date')).details()?.timer).toEqual({ kind: 'date', value: '2030-01-01T00:00:00Z' }); + expect(new EventTrigger(doc.require('startEvent_cron')).details()?.timer).toEqual({ kind: 'cycle', value: '0 0 * * *' }); + expect(new EventTrigger(doc.require('event_inferred')).describe()).toBe('PT1H'); + expect(new EventTrigger(doc.require('event_duration')).describe()).toBe('PT5M'); const { xml } = await roundTrip(doc); expect(xml).toMatch(/R\/PT1H<\/bpmn:timeCycle>/); expect(xml).toMatch(/2030-01-01T00:00:00Z<\/bpmn:timeDate>/); @@ -385,13 +384,13 @@ describe('triggers', () => { add(doc, { kind: 'endEvent:compensate', name: 'Comp', in: 'Process_1' }); add(doc, { kind: 'endEvent:cancel', name: 'Cancel', in: 'Process_1' }); add(doc, { kind: 'boundaryEvent:compensate', name: 'Undo', on: 'Task_A' }); - expect(new EventTrigger(doc.require('Event_Cond')).describe()).toBe('condition ${x > 1}'); - expect(new EventTrigger(doc.require('Event_Jump')).describe()).toBe('link L1'); - expect(new EventTrigger(doc.require('Event_L1')).describe()).toBe('link L1'); - expect(new EventTrigger(doc.require('Event_Stop')).describe()).toBe('terminate'); - expect(new EventTrigger(doc.require('Event_Comp')).describe()).toBe('compensate'); - expect(new EventTrigger(doc.require('Event_Cancel')).describe()).toBe('cancel'); - expect(new EventTrigger(doc.require('Event_Undo')).describe()).toBe('compensate'); + expect(new EventTrigger(doc.require('event_cond')).describe()).toBe('condition ${x > 1}'); + expect(new EventTrigger(doc.require('event_jump')).describe()).toBe('link L1'); + expect(new EventTrigger(doc.require('event_l1')).describe()).toBe('link L1'); + expect(new EventTrigger(doc.require('endEvent_stop')).describe()).toBe('terminate'); + expect(new EventTrigger(doc.require('endEvent_comp')).describe()).toBe('compensate'); + expect(new EventTrigger(doc.require('endEvent_cancel')).describe()).toBe('cancel'); + expect(new EventTrigger(doc.require('event_undo')).describe()).toBe('compensate'); expect(new EventTrigger(doc.require('Start')).describe()).toBe(''); expect(new EventTrigger(doc.require('Task_A')).details()).toBeUndefined(); const { xml } = await roundTrip(doc); @@ -403,17 +402,17 @@ describe('triggers', () => { const doc = await linearDoc(); const cs = add(doc, { kind: 'boundaryEvent:timer', name: 'NI', on: 'Task_A', timer: 'PT1H', nonInterrupting: true }); expect(cs.created[0]!.kind).toBe('boundaryEvent:timer'); - expect(doc.require('Event_NI').get('cancelActivity')).toBe(false); + expect(doc.require('event_ni').get('cancelActivity')).toBe(false); expect(codeOf(() => add(doc, { kind: 'boundaryEvent:error', on: 'Task_A', nonInterrupting: true }))).toBe('E_INVALID_TRIGGER'); expect(codeOf(() => add(doc, { kind: 'boundaryEvent:cancel', on: 'Task_A', nonInterrupting: true }))).toBe('E_INVALID_TRIGGER'); expect(codeOf(() => add(doc, { kind: 'startEvent:message', message: 'M', nonInterrupting: true }))).toBe('E_INVALID_TRIGGER'); expect(codeOf(() => add(doc, { kind: 'endEvent', nonInterrupting: true }))).toBe('E_INVALID_TRIGGER'); // retriggering a non-interrupting boundary event to error makes it interrupting again - new EventDefinitions(new EditSession(doc)).setTrigger(doc.require('Event_NI'), 'error', { error: 'E' }); - expect(doc.require('Event_NI').get('cancelActivity')).toBe(true); - new EventDefinitions(new EditSession(doc)).setTrigger(doc.require('Event_NI'), 'timer', { timer: 'PT1H', nonInterrupting: true }); - new EventDefinitions(new EditSession(doc)).setTrigger(doc.require('Event_NI'), 'timer', { timer: 'PT1H', nonInterrupting: false }); - expect(doc.require('Event_NI').get('cancelActivity')).toBe(true); + new EventDefinitions(new EditSession(doc)).setTrigger(doc.require('event_ni'), 'error', { error: 'E' }); + expect(doc.require('event_ni').get('cancelActivity')).toBe(true); + new EventDefinitions(new EditSession(doc)).setTrigger(doc.require('event_ni'), 'timer', { timer: 'PT1H', nonInterrupting: true }); + new EventDefinitions(new EditSession(doc)).setTrigger(doc.require('event_ni'), 'timer', { timer: 'PT1H', nonInterrupting: false }); + expect(doc.require('event_ni').get('cancelActivity')).toBe(true); await roundTrip(doc); }); @@ -423,22 +422,22 @@ describe('triggers', () => { expect(codeOf(() => add(doc, { kind: 'startEvent:escalation', escalation: 'E', in: 'Process_1' }))).toBe('E_INVALID_TRIGGER'); expect(codeOf(() => add(doc, { kind: 'startEvent:compensate', in: 'Process_1' }))).toBe('E_INVALID_TRIGGER'); add(doc, { kind: 'eventSubProcess', name: 'On error' }); - expect(codeOf(() => add(doc, { kind: 'startEvent', in: 'Activity_OnError' }))).toBe('E_TRIGGER_REQUIRED'); + expect(codeOf(() => add(doc, { kind: 'startEvent', in: 'subProcess_onError' }))).toBe('E_TRIGGER_REQUIRED'); expect(codeOf(() => add(doc, { kind: 'boundaryEvent', on: 'Task_A' }))).toBe('E_TRIGGER_REQUIRED'); - const cs = add(doc, { kind: 'startEvent:error', name: 'Err', error: 'Boom', errorCode: 'B1', in: 'Activity_OnError' }); - expect(cs.created.map((change) => change.id)).toEqual(['Event_Err', 'Error_Boom']); - add(doc, { kind: 'startEvent:message', name: 'Msg', message: 'Ping', nonInterrupting: true, in: 'Activity_OnError' }); - expect(doc.require('Event_Msg').get('isInterrupting')).toBe(false); - expect(new EventTrigger(doc.require('Event_Msg')).details()?.nonInterrupting).toBe(true); - expect(codeOf(() => add(doc, { kind: 'startEvent:error', error: 'Boom', nonInterrupting: true, in: 'Activity_OnError' }))).toBe('E_INVALID_TRIGGER'); - add(doc, { kind: 'startEvent:timer', name: 'Tick', timer: 'R/PT1H', in: 'Activity_OnError' }); - add(doc, { kind: 'startEvent:signal', name: 'Sig', signal: 'S', in: 'Activity_OnError' }); - add(doc, { kind: 'startEvent:conditional', name: 'Cnd', when: '${a}', in: 'Activity_OnError' }); - add(doc, { kind: 'startEvent:escalation', name: 'Esc', escalation: 'Up', in: 'Activity_OnError' }); - add(doc, { kind: 'startEvent:compensate', name: 'Cmp', in: 'Activity_OnError' }); + const cs = add(doc, { kind: 'startEvent:error', name: 'Err', error: 'Boom', errorCode: 'B1', in: 'subProcess_onError' }); + expect(cs.created.map((change) => change.id)).toEqual(['startEvent_err', 'error_boom']); + add(doc, { kind: 'startEvent:message', name: 'Msg', message: 'Ping', nonInterrupting: true, in: 'subProcess_onError' }); + expect(doc.require('startEvent_msg').get('isInterrupting')).toBe(false); + expect(new EventTrigger(doc.require('startEvent_msg')).details()?.nonInterrupting).toBe(true); + expect(codeOf(() => add(doc, { kind: 'startEvent:error', error: 'Boom', nonInterrupting: true, in: 'subProcess_onError' }))).toBe('E_INVALID_TRIGGER'); + add(doc, { kind: 'startEvent:timer', name: 'Tick', timer: 'R/PT1H', in: 'subProcess_onError' }); + add(doc, { kind: 'startEvent:signal', name: 'Sig', signal: 'S', in: 'subProcess_onError' }); + add(doc, { kind: 'startEvent:conditional', name: 'Cnd', when: '${a}', in: 'subProcess_onError' }); + add(doc, { kind: 'startEvent:escalation', name: 'Esc', escalation: 'Up', in: 'subProcess_onError' }); + add(doc, { kind: 'startEvent:compensate', name: 'Cmp', in: 'subProcess_onError' }); const { xml } = await roundTrip(doc); - expect(xml).toMatch(//); - expect(xml).toMatch(//); + expect(xml).toMatch(//); + expect(xml).toMatch(//); }); it('warns about trigger options on non-events and switches triggers to none', async () => { @@ -446,9 +445,9 @@ describe('triggers', () => { const cs = add(doc, { kind: 'userTask', name: 'T', in: 'Process_1', timer: 'PT1H' }); expect(cs.warnings.map((warning) => warning.code)).toEqual(['W_OPTION_IGNORED']); add(doc, { kind: 'startEvent:message', name: 'M', message: 'X', in: 'Process_1' }); - new EventDefinitions(new EditSession(doc)).setTrigger(doc.require('Event_M'), 'none'); - expect(many(doc.require('Event_M'), 'eventDefinitions')).toHaveLength(0); - expect(new EventTrigger(doc.require('Event_M')).describe()).toBe(''); + new EventDefinitions(new EditSession(doc)).setTrigger(doc.require('startEvent_m'), 'none'); + expect(many(doc.require('startEvent_m'), 'eventDefinitions')).toHaveLength(0); + expect(new EventTrigger(doc.require('startEvent_m')).describe()).toBe(''); expect(codeOf(() => new EventDefinitions(new EditSession(doc)).setTrigger(doc.require('Task_A'), 'timer'))).toBe('E_WRONG_KIND'); expect(codeOf(() => new EventDefinitions(new EditSession(doc)).setTrigger(doc.require('Start'), 'terminate'))).toBe('E_INVALID_TRIGGER'); await roundTrip(doc); @@ -465,34 +464,34 @@ describe('participants', () => { const cs = add(doc, { kind: 'participant', name: 'Shop' }); const collab = cs.created[0]!.id; // named after the process it wraps (Process_1 says nothing, and the process has no name: its kind) - expect(collab).toBe('Collaboration_Process'); + expect(collab).toBe('collaboration_process'); expect(cs.created.map((change) => [change.id, change.kind])).toEqual([ [collab, 'collaboration'], - ['Participant_Shop', 'participant'], + ['participant_shop', 'participant'], ]); expect(cs.created[1]!.detail).toBe('wraps process Process_1'); expect(ids(many(doc.definitions, 'rootElements'))).toEqual([collab, 'Process_1']); - expect(doc.require('Participant_Shop').get('processRef').get('id')).toBe('Process_1'); + expect(doc.require('participant_shop').get('processRef').get('id')).toBe('Process_1'); const cs2 = add(doc, { kind: 'pool', name: 'Customer' }); - expect(cs2.created.map((change) => change.id)).toEqual(['Process_Customer', 'Participant_Customer']); - const proc = doc.require('Process_Customer'); + expect(cs2.created.map((change) => change.id)).toEqual(['customer', 'participant_customer']); + const proc = doc.require('customer'); expect(proc.get('isExecutable')).toBe(false); - expect(doc.participantOf(proc)?.get('id')).toBe('Participant_Customer'); - expect(ids(many(doc.definitions, 'rootElements'))).toEqual([collab, 'Process_1', 'Process_Customer']); + expect(doc.participantOf(proc)?.get('id')).toBe('participant_customer'); + expect(ids(many(doc.definitions, 'rootElements'))).toEqual([collab, 'Process_1', 'customer']); const cs3 = add(doc, { kind: 'participant', name: 'Bank', blackBox: true }); - expect(cs3.created.map((change) => change.id)).toEqual(['Participant_Bank']); - expect(doc.require('Participant_Bank').get('processRef')).toBeUndefined(); - expect(ids(doc.participants())).toEqual(['Participant_Shop', 'Participant_Customer', 'Participant_Bank']); + expect(cs3.created.map((change) => change.id)).toEqual(['participant_bank']); + expect(doc.require('participant_bank').get('processRef')).toBeUndefined(); + expect(ids(doc.participants())).toEqual(['participant_shop', 'participant_customer', 'participant_bank']); expect(codeOf(() => add(doc, { kind: 'participant', name: 'X', process: 'Process_1' }))).toBe('E_PROCESS_BOUND'); expect(codeOf(() => add(doc, { kind: 'participant', name: 'X', after: 'Task_A' }))).toBe('E_INVALID_PLACEMENT'); expect(codeOf(() => add(doc, { kind: 'participant', name: 'X', blackBox: true, process: 'Process_1' }))).toBe('E_INVALID_VALUE'); const { xml } = await roundTrip(doc); - expect(xml).toMatch(new RegExp(`\\s*`)); - expect(xml).toMatch(//); + expect(xml).toMatch(new RegExp(`\\s*`)); + expect(xml).toMatch(//); }); it('binds an explicitly named unbound process', async () => { @@ -502,7 +501,7 @@ describe('participants', () => { expect(codeOf(() => add(doc, { kind: 'participant', name: 'A' }))).toBe('E_AMBIGUOUS_SCOPE'); add(doc, { kind: 'participant', name: 'A', process: 'Process_1' }); add(doc, { kind: 'participant', name: 'B', process: 'Process_2' }); - expect(doc.participantOf(doc.require('Process_2'))?.get('id')).toBe('Participant_B'); + expect(doc.participantOf(doc.require('Process_2'))?.get('id')).toBe('participant_b'); await roundTrip(doc); }); }); @@ -515,22 +514,22 @@ describe('lanes', () => { it('creates the lane set, assigns members and warns without a pool', async () => { const doc = await linearDoc(); const cs = add(doc, { kind: 'lane', name: 'Sales', members: ['Start', 'Task_A'] }); - expect(cs.created[0]).toMatchObject({ id: 'Lane_Sales', kind: 'lane', name: 'Sales', detail: 'in Process_1' }); + expect(cs.created[0]).toMatchObject({ id: 'lane_sales', kind: 'lane', name: 'Sales', detail: 'in Process_1' }); expect(cs.warnings.map((warning) => warning.code)).toEqual(['W_LANES_WITHOUT_POOL']); - expect(cs.changed.map((change) => `${change.id}:${change.detail}`)).toEqual(['Start:lane Lane_Sales', 'Task_A:lane Lane_Sales']); + expect(cs.changed.map((change) => `${change.id}:${change.detail}`)).toEqual(['Start:lane lane_sales', 'Task_A:lane lane_sales']); const laneSet = ids(many(doc.require('Process_1'), 'laneSets')); expect(laneSet).toHaveLength(1); - expect(laneSet[0]).toBe('LaneSet_Process'); - expect(ids(many(doc.require('Lane_Sales'), 'flowNodeRef'))).toEqual(['Start', 'Task_A']); + expect(laneSet[0]).toBe('laneSet_process'); + expect(ids(many(doc.require('lane_sales'), 'flowNodeRef'))).toEqual(['Start', 'Task_A']); add(doc, { kind: 'participant', name: 'Shop' }); - const cs2 = add(doc, { kind: 'lane', name: 'Backoffice', in: 'Participant_Shop', members: ['End', 'Task_A'] }); + const cs2 = add(doc, { kind: 'lane', name: 'Backoffice', in: 'participant_shop', members: ['End', 'Task_A'] }); expect(cs2.warnings).toHaveLength(0); - expect(ids(many(doc.require('Lane_Sales'), 'flowNodeRef'))).toEqual(['Start']); - expect(ids(many(doc.require('Lane_Backoffice'), 'flowNodeRef'))).toEqual(['End', 'Task_A']); - expect(laneOf(doc, doc.require('Task_A'))?.get('id')).toBe('Lane_Backoffice'); + expect(ids(many(doc.require('lane_sales'), 'flowNodeRef'))).toEqual(['Start']); + expect(ids(many(doc.require('lane_backoffice'), 'flowNodeRef'))).toEqual(['End', 'Task_A']); + expect(laneOf(doc, doc.require('Task_A'))?.get('id')).toBe('lane_backoffice'); const { xml } = await roundTrip(doc); - expect(xml).toMatch(/\s*\s*Start<\/bpmn:flowNodeRef>/); + expect(xml).toMatch(/\s*\s*Start<\/bpmn:flowNodeRef>/); }); it('nests lanes inside a lane, moving the parent members into the first child', async () => { @@ -540,18 +539,18 @@ describe('lanes', () => { expect(cs.created[0]!.detail).toBe('in lane Lane_Sales'); expect(cs.notes.some((note) => note.includes('moved 2 member(s)'))).toBe(true); expect(ids(many(doc.require('Lane_Sales'), 'flowNodeRef'))).toEqual([]); - expect(ids(many(doc.require('Lane_Inbound'), 'flowNodeRef'))).toEqual(['Start', 'Task_A']); + expect(ids(many(doc.require('lane_inbound'), 'flowNodeRef'))).toEqual(['Start', 'Task_A']); add(doc, { kind: 'lane', name: 'Outbound', in: 'Lane_Sales', members: ['Task_A'] }); - expect(ids(many(doc.require('Lane_Inbound'), 'flowNodeRef'))).toEqual(['Start']); - expect(laneOf(doc, doc.require('Task_A'))?.get('id')).toBe('Lane_Outbound'); - expect(ids(allLanes(doc.require('Process_1')))).toEqual(['Lane_Sales', 'Lane_Inbound', 'Lane_Outbound', 'Lane_Backoffice']); + expect(ids(many(doc.require('lane_inbound'), 'flowNodeRef'))).toEqual(['Start']); + expect(laneOf(doc, doc.require('Task_A'))?.get('id')).toBe('lane_outbound'); + expect(ids(allLanes(doc.require('Process_1')))).toEqual(['Lane_Sales', 'lane_inbound', 'lane_outbound', 'Lane_Backoffice']); // parent lanes with children cannot take members directly expect(codeOf(() => new Lanes(new EditSession(doc)).assign(doc.require('End'), doc.require('Lane_Sales'), new ChangeSet()))).toBe('E_INVALID_LANE_MEMBERSHIP'); // nodes added after a member inherit the nested lane add(doc, { kind: 'task', name: 'Ship', after: 'Task_A' }); - expect(laneOf(doc, doc.require('Task_Ship'))?.get('id')).toBe('Lane_Outbound'); + expect(laneOf(doc, doc.require('task_ship'))?.get('id')).toBe('lane_outbound'); const { xml } = await roundTrip(doc); - expect(xml).toMatch(/\s*\s*/); + expect(xml).toMatch(/\s*\s*/); }); it('Lanes.assign moves nodes with their boundary events and rejects sub-process children', async () => { @@ -559,19 +558,19 @@ describe('lanes', () => { add(doc, { kind: 'boundaryEvent:timer', name: 'BE', on: 'Task_A', timer: 'PT1H' }); const cs = new ChangeSet(); new Lanes(new EditSession(doc)).assign(doc.require('Task_A'), doc.require('Lane_Backoffice'), cs); - expect(ids(many(doc.require('Lane_Backoffice'), 'flowNodeRef'))).toEqual(['End', 'Task_A', 'Event_BE']); + expect(ids(many(doc.require('Lane_Backoffice'), 'flowNodeRef'))).toEqual(['End', 'Task_A', 'event_be']); expect(ids(many(doc.require('Lane_Sales'), 'flowNodeRef'))).toEqual(['Start']); new Lanes(new EditSession(doc)).assign(doc.require('Task_A'), undefined, cs); expect(doc.lanesOf(doc.require('Task_A'))).toHaveLength(0); - expect(doc.lanesOf(doc.require('Event_BE'))).toHaveLength(0); + expect(doc.lanesOf(doc.require('event_be'))).toHaveLength(0); expect(cs.changed.some((change) => change.id === 'Task_A' && change.detail === 'removed from lanes')).toBe(true); add(doc, { kind: 'subProcess', name: 'Sub', after: 'Task_A' }); - add(doc, { kind: 'task', name: 'Inner', in: 'Task_Sub' }); - expect(codeOf(() => add(doc, { kind: 'task', name: 'Inner2', after: 'Task_Inner', lane: 'Lane_Sales' }))).toBe('E_INVALID_LANE_MEMBERSHIP'); - expect(codeOf(() => add(doc, { kind: 'lane', name: 'Nope', in: 'Task_Sub' }))).toBe('E_INVALID_SCOPE'); + add(doc, { kind: 'task', name: 'Inner', in: 'subProcess_sub' }); + expect(codeOf(() => add(doc, { kind: 'task', name: 'Inner2', after: 'task_inner', lane: 'Lane_Sales' }))).toBe('E_INVALID_LANE_MEMBERSHIP'); + expect(codeOf(() => add(doc, { kind: 'lane', name: 'Nope', in: 'subProcess_sub' }))).toBe('E_INVALID_SCOPE'); // the sub-process itself can be a member and inner nodes silently inherit nothing - const cs2 = add(doc, { kind: 'task', name: 'Inner3', after: 'Task_Inner' }); + const cs2 = add(doc, { kind: 'task', name: 'Inner3', after: 'task_inner' }); expect(cs2.changed).toHaveLength(0); await roundTrip(doc); }); @@ -585,36 +584,36 @@ describe('data objects, stores and annotations', () => { it('creates data objects with a backing bpmn:DataObject and data associations in both directions', async () => { const doc = await linearDoc(); const cs = add(doc, { kind: 'dataObject', name: 'Order' }); - expect(cs.created[0]).toMatchObject({ id: 'DataObjectReference_Order', kind: 'dataObject', name: 'Order' }); - expect(doc.require('DataObjectReference_Order').get('dataObjectRef').get('id')).toBe('DataObject_Order'); - expect(is(doc.require('DataObject_Order'), 'bpmn:DataObject')).toBe(true); + expect(cs.created[0]).toMatchObject({ id: 'dataObject_order', kind: 'dataObject', name: 'Order' }); + expect(doc.require('dataObject_order').get('dataObjectRef').get('id')).toBe('dataObjectItem_order'); + expect(is(doc.require('dataObjectItem_order'), 'bpmn:DataObject')).toBe(true); - const out = connect(doc, { source: 'Task_A', target: 'DataObjectReference_Order' }); + const out = connect(doc, { source: 'Task_A', target: 'dataObject_order' }); const outId = out.created[0]!.id; - expect(outId).toBe('DataOutputAssociation_DoAToOrder'); - expect(out.created[0]).toMatchObject({ kind: 'dataAssociation', detail: 'Task_A -> DataObjectReference_Order' }); - const inp = connect(doc, { source: 'DataObjectReference_Order', target: 'Task_A' }); + expect(outId).toBe('association_doAToOrder'); + expect(out.created[0]).toMatchObject({ kind: 'dataAssociation', detail: 'Task_A -> dataObject_order' }); + const inp = connect(doc, { source: 'dataObject_order', target: 'Task_A' }); const inId = inp.created[0]!.id; - expect(inId).toBe('DataInputAssociation_OrderToDoA'); + expect(inId).toBe('association_orderToDoA'); expect(inp.created[0]).toMatchObject({ kind: 'dataAssociation' }); const task = doc.require('Task_A'); expect(ids(many(task, 'dataOutputAssociations'))).toEqual([outId]); expect(ids(many(task, 'dataInputAssociations'))).toEqual([inId]); const prop = many(task, 'properties')[0]!; expect(prop.get('name')).toBe(PLACEHOLDER_PROPERTY); - expect(prop.get('id')).toBe('Property_DoA'); + expect(prop.get('id')).toBe('property_doA'); expect(doc.require(inId).get('targetRef')).toBe(prop); expect(codeOf(() => add(doc, { kind: 'dataObject', name: 'X', after: 'Task_A' }))).toBe('E_INVALID_PLACEMENT'); - expect(codeOf(() => connect(doc, { source: 'DataObjectReference_Order', target: 'DataObjectReference_Order' }))).toBe('E_INVALID_ENDPOINT'); - expect(connect(doc, { source: 'Task_A', target: 'DataObjectReference_Order', ifAbsent: true }).isEmpty).toBe(true); + expect(codeOf(() => connect(doc, { source: 'dataObject_order', target: 'dataObject_order' }))).toBe('E_INVALID_ENDPOINT'); + expect(connect(doc, { source: 'Task_A', target: 'dataObject_order', ifAbsent: true }).isEmpty).toBe(true); const { xml } = await roundTrip(doc); - expect(xml).toMatch(/\s*/); + expect(xml).toMatch(/\s*/); const propId = String(prop.get('id')); expect(xml).toContain(``); - expect(xml).toMatch(new RegExp(`\\s*DataObjectReference_Order\\s*${propId}`)); - expect(xml).toMatch(new RegExp(`\\s*DataObjectReference_Order`)); + expect(xml).toMatch(new RegExp(`\\s*dataObject_order\\s*${propId}`)); + expect(xml).toMatch(new RegExp(`\\s*dataObject_order`)); // removal cleans the placeholder property new Associations(new EditSession(doc)).removeDataAssociation(doc.require(inId)); @@ -627,42 +626,42 @@ describe('data objects, stores and annotations', () => { it('creates data stores with a root bpmn:DataStore and --to wiring', async () => { const doc = await linearDoc(); const cs = add(doc, { kind: 'dataStore', name: 'Order DB', to: 'Task_A' }); - expect(cs.created.map((change) => change.id)).toEqual(['DataStoreReference_OrderDB', 'DataInputAssociation_OrderDBToDoA']); - expect(doc.rootElementsOfType('bpmn:DataStore').map((store) => store.get('id'))).toEqual(['DataStore_OrderDB']); - expect(doc.require('DataStoreReference_OrderDB').get('dataStoreRef').get('id')).toBe('DataStore_OrderDB'); - expect(codeOf(() => connect(doc, { source: 'Start', target: 'DataStoreReference_OrderDB' }))).toBe('NO_ERROR'); - expect(codeOf(() => connect(doc, { source: 'DataStoreReference_OrderDB', target: 'Start' }))).toBe('E_INVALID_ENDPOINT'); + expect(cs.created.map((change) => change.id)).toEqual(['dataStore_orderDB', 'association_orderDBToDoA']); + expect(doc.rootElementsOfType('bpmn:DataStore').map((store) => store.get('id'))).toEqual(['dataStoreItem_orderDB']); + expect(doc.require('dataStore_orderDB').get('dataStoreRef').get('id')).toBe('dataStoreItem_orderDB'); + expect(codeOf(() => connect(doc, { source: 'Start', target: 'dataStore_orderDB' }))).toBe('NO_ERROR'); + expect(codeOf(() => connect(doc, { source: 'dataStore_orderDB', target: 'Start' }))).toBe('E_INVALID_ENDPOINT'); add(doc, { kind: 'parallelGateway', name: 'G', in: 'Process_1' }); - expect(codeOf(() => connect(doc, { source: 'Gateway_G', target: 'DataStoreReference_OrderDB' }))).toBe('E_INVALID_ENDPOINT'); + expect(codeOf(() => connect(doc, { source: 'gateway_g', target: 'dataStore_orderDB' }))).toBe('E_INVALID_ENDPOINT'); const { xml } = await roundTrip(doc); - expect(xml).toMatch(//); - expect(xml).toMatch(//); + expect(xml).toMatch(//); + expect(xml).toMatch(//); }); it('creates text annotations and associations via connect / --to', async () => { const doc = await linearDoc(); const cs = add(doc, { kind: 'textAnnotation', text: 'Check twice' }); const note = cs.created[0]!.id; - expect(note).toBe('TextAnnotation_CheckTwice'); + expect(note).toBe('textAnnotation_checkTwice'); expect(cs.created[0]).toMatchObject({ kind: 'textAnnotation', name: 'Check twice' }); const assoc = connect(doc, { source: note, target: 'Task_A' }); const assocId = assoc.created[0]!.id; - expect(assocId).toBe('Association_CheckTwiceToDoA'); + expect(assocId).toBe('association_checkTwiceToDoA'); expect(assoc.created[0]).toMatchObject({ kind: 'association', detail: `${note} -> Task_A` }); expect(ids(many(doc.require('Process_1'), 'artifacts'))).toEqual([note, assocId]); expect(connect(doc, { source: 'Task_A', target: note, ifAbsent: true }).isEmpty).toBe(true); expect(codeOf(() => connect(doc, { source: 'Task_A', target: note, condition: 'x' }))).toBe('E_INVALID_VALUE'); - // the positional name gives the id (Prefix_Slug like every other kind) and, without --text, the text + // the positional name gives the id (prefix_camelCase like every other kind) and, without --text, the text const cs2 = add(doc, { kind: 'note', name: 'Note via name', to: 'End' }); const assoc2 = cs2.created[1]!.id; - expect(cs2.created.map((change) => change.id)).toEqual(['TextAnnotation_NoteViaName', assoc2]); - expect(assoc2).toBe('Association_EndToNoteViaName'); - expect(doc.require('TextAnnotation_NoteViaName').get('text')).toBe('Note via name'); - expect(edge(doc, assoc2)).toBe('End->TextAnnotation_NoteViaName'); + expect(cs2.created.map((change) => change.id)).toEqual(['textAnnotation_noteViaName', assoc2]); + expect(assoc2).toBe('association_endToNoteViaName'); + expect(doc.require('textAnnotation_noteViaName').get('text')).toBe('Note via name'); + expect(edge(doc, assoc2)).toBe('End->textAnnotation_noteViaName'); const two = add(doc, { kind: 'textAnnotation', text: 'Two' }).created[0]!.id; expect(two).not.toBe(note); - expect(codeOf(() => connect(doc, { source: 'TextAnnotation_NoteViaName', target: two }))).toBe('E_INVALID_ENDPOINT'); + expect(codeOf(() => connect(doc, { source: 'textAnnotation_noteViaName', target: two }))).toBe('E_INVALID_ENDPOINT'); const { xml } = await roundTrip(doc); expect(xml).toMatch(new RegExp(`\\s*Check twice\\s*`)); expect(xml).toContain(``); @@ -672,21 +671,21 @@ describe('data objects, stores and annotations', () => { const doc = await linearDoc(); add(doc, { kind: 'boundaryEvent:compensate', name: 'Undo', on: 'Task_A' }); add(doc, { kind: 'task', name: 'Undo A', in: 'Process_1' }); - const cs = connect(doc, { source: 'Event_Undo', target: 'Activity_UndoA' }); - expect(cs.created[0]).toMatchObject({ id: 'Association_UndoToUndoA', kind: 'association' }); + const cs = connect(doc, { source: 'event_undo', target: 'task_undoA' }); + expect(cs.created[0]).toMatchObject({ id: 'association_undoToUndoA', kind: 'association' }); expect(doc.require(cs.created[0]!.id).get('associationDirection')).toBe('One'); - expect(doc.require('Activity_UndoA').get('isForCompensation')).toBe(true); + expect(doc.require('task_undoA').get('isForCompensation')).toBe(true); const { xml } = await roundTrip(doc); - expect(xml).toMatch(//); + expect(xml).toMatch(//); }); it('createDataAssociation validates node kinds', async () => { const doc = await linearDoc(); add(doc, { kind: 'dataObject', name: 'D' }); const cs = new ChangeSet(); - expect(codeOf(() => new Associations(new EditSession(doc)).createDataAssociation(doc.require('DataObjectReference_D'), doc.require('End'), {}, cs))).toBe('NO_ERROR'); - expect(codeOf(() => new Associations(new EditSession(doc)).createDataAssociation(doc.require('End'), doc.require('DataObjectReference_D'), {}, cs))).toBe('E_INVALID_ENDPOINT'); - expect(codeOf(() => new Associations(new EditSession(doc)).createDataAssociation(doc.require('Start'), doc.require('DataObjectReference_D'), {}, cs))).toBe('NO_ERROR'); + expect(codeOf(() => new Associations(new EditSession(doc)).createDataAssociation(doc.require('dataObject_d'), doc.require('End'), {}, cs))).toBe('NO_ERROR'); + expect(codeOf(() => new Associations(new EditSession(doc)).createDataAssociation(doc.require('End'), doc.require('dataObject_d'), {}, cs))).toBe('E_INVALID_ENDPOINT'); + expect(codeOf(() => new Associations(new EditSession(doc)).createDataAssociation(doc.require('Start'), doc.require('dataObject_d'), {}, cs))).toBe('NO_ERROR'); await roundTrip(doc); }); }); @@ -699,16 +698,16 @@ describe('connect', () => { it('creates sequence flows with options and warns about implicit splits/joins', async () => { const doc = await linearDoc(); add(doc, { kind: 'task', name: 'B', in: 'Process_1' }); - const cs = connect(doc, { source: 'Task_A', target: 'Activity_B', name: 'again', condition: '${retry}', id: 'Flow_Retry' }); - expect(cs.created[0]).toMatchObject({ id: 'Flow_Retry', kind: 'sequenceFlow', name: 'again', detail: 'Task_A -> Activity_B' }); + const cs = connect(doc, { source: 'Task_A', target: 'task_b', name: 'again', condition: '${retry}', id: 'Flow_Retry' }); + expect(cs.created[0]).toMatchObject({ id: 'Flow_Retry', kind: 'sequenceFlow', name: 'again', detail: 'Task_A -> task_b' }); expect(cs.warnings.map((warning) => warning.code)).toEqual(['W_IMPLICIT_SPLIT']); expect(doc.require('Flow_Retry').get('conditionExpression').get('body')).toBe('${retry}'); - const cs2 = connect(doc, { source: 'Activity_B', target: 'End' }); + const cs2 = connect(doc, { source: 'task_b', target: 'End' }); expect(cs2.warnings.map((warning) => warning.code)).toEqual(['W_IMPLICIT_JOIN']); - expect(connect(doc, { source: 'Activity_B', target: 'End', ifAbsent: true }).isEmpty).toBe(true); + expect(connect(doc, { source: 'task_b', target: 'End', ifAbsent: true }).isEmpty).toBe(true); expect(codeOf(() => connect(doc, { source: 'End', target: 'Task_A' }))).toBe('E_INVALID_SOURCE'); expect(codeOf(() => connect(doc, { source: 'Start', target: 'Start' }))).toBe('E_INVALID_ENDPOINT'); - expect(codeOf(() => connect(doc, { source: 'Task_A', target: 'Activity_B', message: 'M' }))).toBe('E_INVALID_VALUE'); + expect(codeOf(() => connect(doc, { source: 'Task_A', target: 'task_b', message: 'M' }))).toBe('E_INVALID_VALUE'); await roundTrip(doc); }); @@ -716,11 +715,11 @@ describe('connect', () => { const doc = await linearDoc(); add(doc, { kind: 'intermediateThrowEvent:link', name: 'To shipping', link: 'Shipping', in: 'Process_1' }); add(doc, { kind: 'intermediateCatchEvent:link', name: 'From order', link: 'Shipping', in: 'Process_1' }); - expect(codeOf(() => connect(doc, { source: 'Event_ToShipping', target: 'End' }))).toBe('E_INVALID_SOURCE'); - expect(codeOf(() => connect(doc, { source: 'Start', target: 'Event_FromOrder' }))).toBe('E_INVALID_TARGET'); - expect(codeOf(() => add(doc, { kind: 'endEvent', name: 'Done', after: 'Event_ToShipping' }))).toBe('E_INVALID_SOURCE'); - expect(connect(doc, { source: 'Task_A', target: 'Event_ToShipping' }).created).toHaveLength(1); - expect(connect(doc, { source: 'Event_FromOrder', target: 'End' }).created).toHaveLength(1); + expect(codeOf(() => connect(doc, { source: 'event_toShipping', target: 'End' }))).toBe('E_INVALID_SOURCE'); + expect(codeOf(() => connect(doc, { source: 'Start', target: 'event_fromOrder' }))).toBe('E_INVALID_TARGET'); + expect(codeOf(() => add(doc, { kind: 'endEvent', name: 'Done', after: 'event_toShipping' }))).toBe('E_INVALID_SOURCE'); + expect(connect(doc, { source: 'Task_A', target: 'event_toShipping' }).created).toHaveLength(1); + expect(connect(doc, { source: 'event_fromOrder', target: 'End' }).created).toHaveLength(1); }); it('gateway sources do not warn about splits, joins through gateways neither', async () => { @@ -734,37 +733,37 @@ describe('connect', () => { it('creates message flows between pools with a root message', async () => { const doc = await linearDoc(); add(doc, { kind: 'sendTask', name: 'Send', after: 'Task_A' }); - expect(codeOf(() => connect(doc, { source: 'Activity_Send', target: 'Task_A', message: 'X' }))).toBe('E_INVALID_VALUE'); + expect(codeOf(() => connect(doc, { source: 'sendTask_send', target: 'Task_A', message: 'X' }))).toBe('E_INVALID_VALUE'); add(doc, { kind: 'participant', name: 'Shop' }); add(doc, { kind: 'participant', name: 'Customer' }); - add(doc, { kind: 'startEvent:message', name: 'Got it', message: 'Offer', in: 'Participant_Customer' }); + add(doc, { kind: 'startEvent:message', name: 'Got it', message: 'Offer', in: 'participant_customer' }); add(doc, { kind: 'participant', name: 'Bank', blackBox: true }); - // message flows follow the sequence flows: the file numbers them F1, F2; new ones keep the prefix, the body names the ends - const cs = connect(doc, { source: 'Activity_Send', target: 'Event_GotIt', message: 'Offer', name: 'offer' }); + // message flows name their ends + const cs = connect(doc, { source: 'sendTask_send', target: 'startEvent_gotIt', message: 'Offer', name: 'offer' }); const offer = cs.created[0]!.id; - expect(offer).toBe('F_SendToGotIt'); - expect(cs.created[0]).toMatchObject({ kind: 'messageFlow', name: 'offer', detail: 'Activity_Send -> Event_GotIt' }); - expect(doc.require(offer).get('messageRef').get('id')).toBe('Message_Offer'); + expect(offer).toBe('messageFlow_sendToGotIt'); + expect(cs.created[0]).toMatchObject({ kind: 'messageFlow', name: 'offer', detail: 'sendTask_send -> startEvent_gotIt' }); + expect(doc.require(offer).get('messageRef').get('id')).toBe('message_offer'); expect(doc.rootElementsOfType('bpmn:Message')).toHaveLength(1); - const cs2 = connect(doc, { source: 'Participant_Bank', target: 'Task_A', message: 'Statement' }); + const cs2 = connect(doc, { source: 'participant_bank', target: 'Task_A', message: 'Statement' }); const statement = cs2.created[1]!.id; - expect(cs2.created.map((change) => change.id)).toEqual(['Message_Statement', statement]); - const cs3 = connect(doc, { source: 'Activity_Send', target: 'Participant_Bank' }); + expect(cs2.created.map((change) => change.id)).toEqual(['message_statement', statement]); + const cs3 = connect(doc, { source: 'sendTask_send', target: 'participant_bank' }); expect(cs3.created[0]!.kind).toBe('messageFlow'); - expect(connect(doc, { source: 'Activity_Send', target: 'Participant_Bank', ifAbsent: true }).isEmpty).toBe(true); + expect(connect(doc, { source: 'sendTask_send', target: 'participant_bank', ifAbsent: true }).isEmpty).toBe(true); expect(ids(doc.messageFlows())).toEqual([offer, statement, cs3.created[0]!.id]); expect(new Set(ids(doc.messageFlows())).size).toBe(3); - expect(codeOf(() => connect(doc, { source: 'Task_A', target: 'Participant_Shop' }))).toBe('E_SAME_POOL'); - expect(codeOf(() => connect(doc, { source: 'Activity_Send', target: 'Event_GotIt', condition: 'x' }))).toBe('E_INVALID_VALUE'); + expect(codeOf(() => connect(doc, { source: 'Task_A', target: 'participant_shop' }))).toBe('E_SAME_POOL'); + expect(codeOf(() => connect(doc, { source: 'sendTask_send', target: 'startEvent_gotIt', condition: 'x' }))).toBe('E_INVALID_VALUE'); add(doc, { kind: 'exclusiveGateway', name: 'G', in: 'Process_1' }); - expect(codeOf(() => connect(doc, { source: 'Gateway_G', target: 'Participant_Bank' }))).toBe('E_INVALID_ENDPOINT'); - expect(codeOf(() => connect(doc, { source: 'Event_GotIt', target: 'Participant_Bank' }))).toBe('E_INVALID_ENDPOINT'); - expect(codeOf(() => connect(doc, { source: 'Participant_Bank', target: 'Activity_Send' }))).toBe('NO_ERROR'); + expect(codeOf(() => connect(doc, { source: 'gateway_g', target: 'participant_bank' }))).toBe('E_INVALID_ENDPOINT'); + expect(codeOf(() => connect(doc, { source: 'startEvent_gotIt', target: 'participant_bank' }))).toBe('E_INVALID_ENDPOINT'); + expect(codeOf(() => connect(doc, { source: 'participant_bank', target: 'sendTask_send' }))).toBe('NO_ERROR'); const { xml } = await roundTrip(doc); - expect(xml).toContain(``); + expect(xml).toContain(``); }); it('refuses message flows without a collaboration and unrelated endpoints', async () => { @@ -795,27 +794,27 @@ describe('split', () => { }; const cs = new BranchSplit(new EditSession(doc)).split(op); const created = cs.created.filter((change) => change.kind !== 'sequenceFlow').map((change) => change.id); - expect(created).toEqual(['Gateway_InvoiceOk', 'Activity_Book', 'Activity_Archive', 'Activity_Clarify', 'Gateway_InvoiceOk_join']); - expect(cs.created.find((change) => change.id === 'Gateway_InvoiceOk')!.detail).toBe('between Task_A and End'); - expect(cs.created.find((change) => change.id === 'Gateway_InvoiceOk_join')!.detail).toBe('join of Gateway_InvoiceOk'); + expect(created).toEqual(['gateway_invoiceOk', 'serviceTask_book', 'userTask_archive', 'userTask_clarify', 'gateway_invoiceOkJoin']); + expect(cs.created.find((change) => change.id === 'gateway_invoiceOk')!.detail).toBe('between Task_A and End'); + expect(cs.created.find((change) => change.id === 'gateway_invoiceOkJoin')!.detail).toBe('join of gateway_invoiceOk'); expect(cs.warnings).toHaveLength(0); - const gw = doc.require('Gateway_InvoiceOk'); - const join = doc.require('Gateway_InvoiceOk_join'); - expect(edge(doc, 'F2')).toBe('Task_A->Gateway_InvoiceOk'); + const gw = doc.require('gateway_invoiceOk'); + const join = doc.require('gateway_invoiceOkJoin'); + expect(edge(doc, 'F2')).toBe('Task_A->gateway_invoiceOk'); const out = doc.outgoing(gw); - expect(out.map((flow) => `${flow.get('name')}:${flow.get('targetRef').get('id')}`)).toEqual(['yes:Activity_Book', 'no:Activity_Clarify']); + expect(out.map((flow) => `${flow.get('name')}:${flow.get('targetRef').get('id')}`)).toEqual(['yes:serviceTask_book', 'no:userTask_clarify']); expect(out[0]!.get('conditionExpression').get('body')).toBe('${ok}'); expect(gw.get('default')).toBe(out[1]); - expect(edge(doc, ids(doc.outgoing(doc.require('Activity_Book')))[0]!)).toBe('Activity_Book->Activity_Archive'); - expect(doc.incoming(join).map((flow) => flow.get('sourceRef').get('id'))).toEqual(['Activity_Archive', 'Activity_Clarify']); + expect(edge(doc, ids(doc.outgoing(doc.require('serviceTask_book')))[0]!)).toBe('serviceTask_book->userTask_archive'); + expect(doc.incoming(join).map((flow) => flow.get('sourceRef').get('id'))).toEqual(['userTask_archive', 'userTask_clarify']); expect(doc.outgoing(join).map((flow) => flow.get('targetRef').get('id'))).toEqual(['End']); expect(doc.incoming(doc.require('End'))).toHaveLength(1); // declaration order: gateway right after its incoming flow, branches in order const order = flowIds(doc); - expect(order.indexOf('Gateway_InvoiceOk')).toBe(order.indexOf('F2') + 1); - expect(order.indexOf('Activity_Book')).toBeLessThan(order.indexOf('Activity_Clarify')); - expect(cs.notes.some((note) => note.startsWith('split Gateway_InvoiceOk'))).toBe(true); + expect(order.indexOf('gateway_invoiceOk')).toBe(order.indexOf('F2') + 1); + expect(order.indexOf('serviceTask_book')).toBeLessThan(order.indexOf('userTask_clarify')); + expect(cs.notes.some((note) => note.startsWith('split gateway_invoiceOk'))).toBe(true); await roundTrip(doc); }); @@ -829,10 +828,10 @@ describe('split', () => { branches: [{ nodes: [{ kind: 'task', name: 'P1' }] }, { nodes: [{ kind: 'task', name: 'P2' }] }], }); const gateway = cs.created.find((change) => change.kind === 'parallelGateway')!.id; - expect(gateway).toBe('Gateway_AfterDoA'); + expect(gateway).toBe('gateway_afterDoA'); expect(is(doc.require(gateway), 'bpmn:ParallelGateway')).toBe(true); - expect(doc.has(`${gateway}_join`)).toBe(false); - expect(doc.incoming(doc.require('End')).map((flow) => flow.get('sourceRef').get('id'))).toEqual(['Activity_P1', 'Activity_P2']); + expect(doc.has(`${gateway}Join`)).toBe(false); + expect(doc.incoming(doc.require('End')).map((flow) => flow.get('sourceRef').get('id'))).toEqual(['task_p1', 'task_p2']); expect(cs.warnings.map((warning) => warning.code)).toEqual(['W_IMPLICIT_JOIN']); await roundTrip(doc); }); @@ -850,7 +849,7 @@ describe('split', () => { expect(cs.created.find((change) => change.id === 'Gateway_Split')!.detail).toBe('after Loose'); expect(edge(doc, ids(doc.outgoing(doc.require('Loose')))[0]!)).toBe('Loose->Gateway_Split'); const gwOut = doc.outgoing(doc.require('Gateway_Split')); - expect(gwOut.map((flow) => flow.get('targetRef').get('id'))).toEqual(['Activity_Work', 'Gateway_Merge']); + expect(gwOut.map((flow) => flow.get('targetRef').get('id'))).toEqual(['task_work', 'Gateway_Merge']); expect(gwOut[1]!.get('name')).toBe('skip'); expect(doc.outgoing(doc.require('Gateway_Merge'))).toHaveLength(0); expect(doc.require('Gateway_Merge').get('name')).toBe('merge'); @@ -868,7 +867,7 @@ describe('split', () => { expect(codeOf(() => new BranchSplit(new EditSession(doc)).split({ op: 'split', after: 'Task_A', branches: [] }))).toBe('E_INVALID_VALUE'); expect(codeOf(() => new BranchSplit(new EditSession(doc)).split({ op: 'split', after: 'Nope', branches: [{ nodes: [] }] }))).toBe('E_NOT_FOUND'); add(doc, { kind: 'task', name: 'Alone', in: 'Process_1' }); - expect(codeOf(() => new BranchSplit(new EditSession(doc)).split({ op: 'split', after: 'Activity_Alone', join: false, branches: [{ nodes: [] }] }))).toBe('E_INVALID_VALUE'); + expect(codeOf(() => new BranchSplit(new EditSession(doc)).split({ op: 'split', after: 'task_alone', join: false, branches: [{ nodes: [] }] }))).toBe('E_INVALID_VALUE'); expect(codeOf(() => new BranchSplit(new EditSession(doc)).split({ op: 'split', after: 'Task_A', kind: 'choreographyTask', branches: [{ nodes: [] }] }))).toBe('E_UNKNOWN_KIND'); }); @@ -914,8 +913,8 @@ describe('split', () => { expect(await doc.toXml()).toBe(before); // an empty nodes array is still a direct gateway -> join flow const result = await mutateDoc(doc, [{ op: 'split', after: 'Task_A', branches: [{ nodes: [{ kind: 'task', name: 'A' }] }, { flowName: 'no', nodes: [] }] }], { layout: false }); - expect(result.changes.created.map((change) => change.id)).toContain('Gateway_AfterDoA_join'); - expect(flowBetween(doc, 'Gateway_AfterDoA', 'Gateway_AfterDoA_join')).toBeTruthy(); + expect(result.changes.created.map((change) => change.id)).toContain('gateway_afterDoAJoin'); + expect(flowBetween(doc, 'gateway_afterDoA', 'gateway_afterDoAJoin')).toBeTruthy(); }); it('branch nodes inherit the anchor lane and can carry triggers', async () => { @@ -931,8 +930,8 @@ describe('split', () => { ], }); expect(laneOf(doc, doc.require(cs.created.find((change) => change.kind === 'eventBasedGateway')!.id))?.get('id')).toBe('Lane_Sales'); - expect(laneOf(doc, doc.require('Event_Timeout'))?.get('id')).toBe('Lane_Sales'); - expect(new EventTrigger(doc.require('Event_Reply')).describe()).toBe('message Reply'); + expect(laneOf(doc, doc.require('event_timeout'))?.get('id')).toBe('Lane_Sales'); + expect(new EventTrigger(doc.require('event_reply')).describe()).toBe('message Reply'); await roundTrip(doc); }); }); @@ -955,15 +954,15 @@ describe('add: event sub-processes with a trigger', () => { const doc = await linearDoc(); const cs = add(doc, { kind: 'eventSubProcess', name: 'Handle errors', error: 'PaymentFailed', errorCode: 'PAY-1' }); const start = cs.created[1]!.id; - expect(start).toBe('Event_ErrorStartInHandleErrors'); + expect(start).toBe('startEvent_errorInHandleErrors'); expect(cs.created.map((change) => [change.id, change.kind])).toEqual([ - ['Activity_HandleErrors', 'eventSubProcess'], + ['subProcess_handleErrors', 'eventSubProcess'], [start, 'startEvent:error'], - ['Error_PaymentFailed', 'error'], + ['error_paymentFailed', 'error'], ]); - expect(cs.created[1]!.detail).toBe('start of event sub-process Activity_HandleErrors'); + expect(cs.created[1]!.detail).toBe('start of event sub-process subProcess_handleErrors'); expect(cs.warnings).toHaveLength(0); - expect(ids(doc.flowNodes(doc.require('Activity_HandleErrors')))).toEqual([start]); + expect(ids(doc.flowNodes(doc.require('subProcess_handleErrors')))).toEqual([start]); expect(new EventTrigger(doc.require(start)).describe()).toBe('error PaymentFailed (PAY-1)'); const cs2 = add(doc, { kind: 'eventSubProcess:message', name: 'On cancel', message: 'Cancel', nonInterrupting: true, in: 'Process_1' }); @@ -971,12 +970,12 @@ describe('add: event sub-processes with a trigger', () => { const start2 = cs2.created[1]!.id; expect(start2).not.toBe(start); expect(doc.require(start2).get('isInterrupting')).toBe(false); - expect(doc.scopeOf(doc.require(start2))?.get('id')).toBe('Activity_OnCancel'); + expect(doc.scopeOf(doc.require(start2))?.get('id')).toBe('subProcess_onCancel'); // --timer infers the trigger as well, aliases work const cs3 = add(doc, { kind: 'eventSub', name: 'Tick', timer: 'R/PT1H' }); expect(new EventTrigger(doc.require(cs3.created[1]!.id)).details()?.timer).toEqual({ kind: 'cycle', value: 'R/PT1H' }); const { xml } = await roundTrip(doc); - expect(xml).toMatch(new RegExp(`\\s*\\s*`)); + expect(xml).toMatch(new RegExp(`\\s*\\s*`)); }); it('rejects triggers a start event cannot have; without a trigger nothing is created inside', async () => { @@ -984,12 +983,12 @@ describe('add: event sub-processes with a trigger', () => { expect(codeOf(() => add(doc, { kind: 'eventSubProcess:terminate', name: 'X' }))).toBe('E_INVALID_TRIGGER'); expect(codeOf(() => add(doc, { kind: 'eventSubProcess:nope', name: 'X' }))).toBe('E_INVALID_TRIGGER'); expect(caught(() => add(doc, { kind: 'eventSubProcess:terminate', name: 'X' }))?.details?.candidates).toContain('error'); - expect(doc.has('Activity_X')).toBe(false); + expect(doc.has('subProcess_x')).toBe(false); const cs = add(doc, { kind: 'eventSubProcess', name: 'Plain', nonInterrupting: true }); - expect(cs.created.map((change) => change.id)).toEqual(['Activity_Plain']); + expect(cs.created.map((change) => change.id)).toEqual(['subProcess_plain']); expect(cs.warnings.map((warning) => warning.code)).toEqual(['W_OPTION_IGNORED']); expect(add(doc, { kind: 'eventSubProcess', name: 'Empty' }).warnings).toHaveLength(0); - expect(doc.flowNodes(doc.require('Activity_Empty'))).toHaveLength(0); + expect(doc.flowNodes(doc.require('subProcess_empty'))).toHaveLength(0); }); it('the hint for a triggered start outside an event sub-process names the one-command fix', async () => { @@ -1023,10 +1022,10 @@ describe('add: option validation', () => { const err = caught(() => add(doc, { kind: 'userTask', name: 'Check order', after: 'Task_A', ifAbsent: true })); expect(err?.code).toBe('E_USAGE'); expect(err?.exitCode).toBe(1); - expect(err?.details?.hint).toContain('--id Activity_CheckOrder'); - expect(doc.has('Activity_CheckOrder_2')).toBe(false); - expect(edge(doc, 'F2')).toBe('Task_A->Activity_CheckOrder'); - expect(add(doc, { kind: 'userTask', id: 'Activity_CheckOrder', after: 'Task_A', ifAbsent: true }).isEmpty).toBe(true); + expect(err?.details?.hint).toContain('--id userTask_checkOrder'); + expect(doc.has('userTask_checkOrder2')).toBe(false); + expect(edge(doc, 'F2')).toBe('Task_A->userTask_checkOrder'); + expect(add(doc, { kind: 'userTask', id: 'userTask_checkOrder', after: 'Task_A', ifAbsent: true }).isEmpty).toBe(true); }); it('rejects default + condition and empty conditions before mutating anything (add and connect)', async () => { @@ -1034,8 +1033,8 @@ describe('add: option validation', () => { expect(codeOf(() => add(doc, { kind: 'task', name: 'DC', after: 'GW', default: true, condition: '${x}' }))).toBe('E_USAGE'); expect(codeOf(() => add(doc, { kind: 'task', name: 'EC', after: 'GW', condition: '' }))).toBe('E_INVALID_VALUE'); expect(codeOf(() => add(doc, { kind: 'task', name: 'EC', after: 'GW', condition: ' ' }))).toBe('E_INVALID_VALUE'); - expect(doc.has('Activity_DC')).toBe(false); - expect(doc.has('Activity_EC')).toBe(false); + expect(doc.has('task_dc')).toBe(false); + expect(doc.has('task_ec')).toBe(false); expect(ids(doc.outgoing(doc.require('GW')))).toEqual(['F2']); expect(codeOf(() => connect(doc, { source: 'GW', target: 'Loose', default: true, condition: '${x}' }))).toBe('E_USAGE'); expect(codeOf(() => connect(doc, { source: 'GW', target: 'Loose', condition: '' }))).toBe('E_INVALID_VALUE'); @@ -1053,17 +1052,17 @@ describe('add: option validation', () => { expect(cs.warnings[0]!.related).toEqual(['F1']); expect(cs.warnings[0]!.hint).toContain('bpmn set F1 id=Flow_custom'); // after a node with a successor splices too - const cs2 = add(doc, { kind: 'task', name: 'Mid2', after: 'Activity_Mid', flowId: 'Flow_custom2' }); + const cs2 = add(doc, { kind: 'task', name: 'Mid2', after: 'task_mid', flowId: 'Flow_custom2' }); expect(cs2.warnings.map((warning) => warning.code)).toEqual(['W_OPTION_IGNORED']); expect(doc.has('Flow_custom2')).toBe(false); // append / prepend create the flow with that id: no warning add(doc, { kind: 'task', name: 'Loose', in: 'Process_1' }); - const cs3 = add(doc, { kind: 'task', name: 'Tail', after: 'Activity_Loose', flowId: 'Flow_custom3' }); + const cs3 = add(doc, { kind: 'task', name: 'Tail', after: 'task_loose', flowId: 'Flow_custom3' }); expect(cs3.warnings).toHaveLength(0); - expect(edge(doc, 'Flow_custom3')).toBe('Activity_Loose->Activity_Tail'); - const cs4 = add(doc, { kind: 'task', name: 'Head', before: 'Activity_Loose', flowId: 'Flow_custom4', flowName: 'in' }); + expect(edge(doc, 'Flow_custom3')).toBe('task_loose->task_tail'); + const cs4 = add(doc, { kind: 'task', name: 'Head', before: 'task_loose', flowId: 'Flow_custom4', flowName: 'in' }); expect(cs4.warnings).toHaveLength(0); - expect(edge(doc, 'Flow_custom4')).toBe('Activity_Head->Activity_Loose'); + expect(edge(doc, 'Flow_custom4')).toBe('task_head->task_loose'); // --in creates no flow into the node const cs5 = add(doc, { kind: 'task', name: 'Alone', in: 'Process_1', flowName: 'x', condition: '${y}' }); expect(cs5.warnings.map((warning) => warning.code)).toEqual(['W_OPTION_IGNORED']); @@ -1079,20 +1078,20 @@ describe('add: option validation', () => { expect(err?.exitCode).toBe(2); expect(err?.details?.candidates).toEqual(['cycle', 'duration', 'date']); add(doc, { kind: 'startEvent:timer', name: 'Good', timer: 'PT2D', timerKind: 'cycle', in: 'Process_1' }); - expect(new EventTrigger(doc.require('Event_Good')).details()?.timer).toEqual({ kind: 'cycle', value: 'PT2D' }); + expect(new EventTrigger(doc.require('startEvent_good')).details()?.timer).toEqual({ kind: 'cycle', value: 'PT2D' }); }); it('names text annotations by the positional name and warns only on real id collisions', async () => { const doc = await linearDoc(); const cs = add(doc, { kind: 'note', name: 'Approval note', text: 'Offers above 10k need approval' }); - expect(cs.created[0]).toMatchObject({ id: 'TextAnnotation_ApprovalNote', kind: 'textAnnotation', name: 'Offers above 10k need approval' }); - expect(doc.require('TextAnnotation_ApprovalNote').get('text')).toBe('Offers above 10k need approval'); + expect(cs.created[0]).toMatchObject({ id: 'textAnnotation_approvalNote', kind: 'textAnnotation', name: 'Offers above 10k need approval' }); + expect(doc.require('textAnnotation_approvalNote').get('text')).toBe('Offers above 10k need approval'); expect(cs.warnings).toHaveLength(0); const cs2 = add(doc, { kind: 'note', name: 'Approval note' }); - expect(cs2.created[0]!.id).toBe('TextAnnotation_ApprovalNote_2'); + expect(cs2.created[0]!.id).toBe('textAnnotation_approvalNote2'); expect(cs2.warnings.map((warning) => warning.code)).toEqual(['W_ID_SUFFIXED']); const cs3 = add(doc, { kind: 'note', text: 'only text' }); - expect(cs3.created[0]!.id).toBe('TextAnnotation_OnlyText'); + expect(cs3.created[0]!.id).toBe('textAnnotation_onlyText'); expect(cs3.warnings).toHaveLength(0); await roundTrip(doc); }); @@ -1107,16 +1106,16 @@ describe('connect: self-loops', () => { expect(cs.warnings.map((warning) => warning.code)).toEqual(['W_IMPLICIT_SPLIT', 'W_IMPLICIT_JOIN']); expect(connect(doc, { source: 'Task_A', target: 'Task_A', ifAbsent: true }).isEmpty).toBe(true); add(doc, { kind: 'exclusiveGateway', name: 'G', in: 'Process_1' }); - expect(codeOf(() => connect(doc, { source: 'Gateway_G', target: 'Gateway_G' }))).toBe('E_INVALID_ENDPOINT'); + expect(codeOf(() => connect(doc, { source: 'gateway_g', target: 'gateway_g' }))).toBe('E_INVALID_ENDPOINT'); expect(codeOf(() => connect(doc, { source: 'Start', target: 'Start' }))).toBe('E_INVALID_ENDPOINT'); const note = add(doc, { kind: 'textAnnotation', text: 'n' }).created[0]!.id; expect(codeOf(() => connect(doc, { source: note, target: note }))).toBe('E_INVALID_ENDPOINT'); add(doc, { kind: 'participant', name: 'P' }); - expect(codeOf(() => connect(doc, { source: 'Participant_P', target: 'Participant_P' }))).toBe('E_INVALID_ENDPOINT'); + expect(codeOf(() => connect(doc, { source: 'participant_p', target: 'participant_p' }))).toBe('E_INVALID_ENDPOINT'); const { xml } = await roundTrip(doc); expect(xml).toContain(``); - // the file numbers its flows F1, F2: its prefix, a body naming the ends - expect(cs.created[0]!.id).toBe('F_DoAToDoA'); + // a body naming the ends + expect(cs.created[0]!.id).toBe('flow_doAToDoA'); }); }); @@ -1132,12 +1131,12 @@ describe('split: terminating branches', () => { { flowName: 'no', default: true, nodes: [{ kind: 'end', name: 'Rejected' }] }, ], }); - // a join only Activity_Book would reach is no join: none is created - expect(doc.has('Gateway_Ok_join')).toBe(false); - expect(doc.outgoing(doc.require('Activity_Book')).map((flow) => edge(doc, flow.get('id')))).toEqual(['Activity_Book->End']); - expect(doc.outgoing(doc.require('Event_Rejected'))).toHaveLength(0); - expect(doc.require('Gateway_Ok').get('default').get('targetRef').get('id')).toBe('Event_Rejected'); - expect(cs.notes.some((note) => note.includes('Event_Rejected terminates'))).toBe(true); + // a join only task_book would reach is no join: none is created + expect(doc.has('gateway_okJoin')).toBe(false); + expect(doc.outgoing(doc.require('task_book')).map((flow) => edge(doc, flow.get('id')))).toEqual(['task_book->End']); + expect(doc.outgoing(doc.require('endEvent_rejected'))).toHaveLength(0); + expect(doc.require('gateway_ok').get('default').get('targetRef').get('id')).toBe('endEvent_rejected'); + expect(cs.notes.some((note) => note.includes('endEvent_rejected terminates'))).toBe(true); expect(cs.notes.some((note) => note.includes('no join gateway created'))).toBe(true); expect(cs.warnings).toHaveLength(0); await roundTrip(doc); @@ -1146,15 +1145,15 @@ describe('split: terminating branches', () => { it('with every branch terminating no join is created and the lost successor is noted; join:false stops the end branch too', async () => { const doc = await linearDoc(); const cs = new BranchSplit(new EditSession(doc)).split({ op: 'split', after: 'Task_A', branches: [{ nodes: [{ kind: 'end', name: 'A' }] }, { nodes: [{ kind: 'end', name: 'B' }] }] }); - expect(doc.has('Gateway_1_join')).toBe(false); + expect(doc.has('gateway_afterDoAJoin')).toBe(false); expect(doc.incoming(doc.require('End'))).toHaveLength(0); expect(cs.notes.some((note) => note.includes('no join gateway created'))).toBe(true); expect(cs.notes.some((note) => note.includes('End is no longer reached'))).toBe(true); const doc2 = await linearDoc(); const cs2 = new BranchSplit(new EditSession(doc2)).split({ op: 'split', after: 'Task_A', join: false, branches: [{ nodes: [{ kind: 'task', name: 'Go' }] }, { nodes: [{ kind: 'end', name: 'Stop' }] }] }); - expect(doc2.incoming(doc2.require('End')).map((flow) => flow.get('sourceRef').get('id'))).toEqual(['Activity_Go']); - expect(doc2.outgoing(doc2.require('Event_Stop'))).toHaveLength(0); + expect(doc2.incoming(doc2.require('End')).map((flow) => flow.get('sourceRef').get('id'))).toEqual(['task_go']); + expect(doc2.outgoing(doc2.require('endEvent_stop'))).toHaveLength(0); expect(cs2.warnings).toHaveLength(0); await roundTrip(doc2); }); @@ -1165,15 +1164,15 @@ describe('lanes: nested --members', () => { const doc = await docFromXml(WITH_LANES); add(doc, { kind: 'participant', name: 'Shop' }); const cs = add(doc, { kind: 'lane', name: 'Inbound', in: 'Lane_Sales', members: ['Task_A'] }); - expect(ids(many(doc.require('Lane_Inbound'), 'flowNodeRef'))).toEqual(['Start', 'Task_A']); + expect(ids(many(doc.require('lane_inbound'), 'flowNodeRef'))).toEqual(['Start', 'Task_A']); expect(cs.warnings.map((warning) => warning.code)).toEqual(['W_OPTION_IGNORED']); expect(cs.warnings[0]!.related).toEqual(['Start']); expect(cs.warnings[0]!.hint).toContain('--in Lane_Sales --members Start'); // the sibling lane the hint suggests takes exactly its members, no warning const cs2 = add(doc, { kind: 'lane', name: 'Outbound', in: 'Lane_Sales', members: ['Start'] }); expect(cs2.warnings).toHaveLength(0); - expect(ids(many(doc.require('Lane_Inbound'), 'flowNodeRef'))).toEqual(['Task_A']); - expect(ids(many(doc.require('Lane_Outbound'), 'flowNodeRef'))).toEqual(['Start']); + expect(ids(many(doc.require('lane_inbound'), 'flowNodeRef'))).toEqual(['Task_A']); + expect(ids(many(doc.require('lane_outbound'), 'flowNodeRef'))).toEqual(['Start']); // listing every inherited member (or none) is silent const cs3 = add(doc, { kind: 'lane', name: 'Back', in: 'Lane_Backoffice', members: ['End'] }); expect(cs3.warnings).toHaveLength(0); diff --git a/test/domain/editing/ops-mutate.test.ts b/test/domain/editing/ops-mutate.test.ts index 0b3ca038..beb67914 100644 --- a/test/domain/editing/ops-mutate.test.ts +++ b/test/domain/editing/ops-mutate.test.ts @@ -15,6 +15,7 @@ import { Properties } from '../../../src/domain/editing/properties/Properties.js import { ElementProperties } from '../../../src/domain/views/ElementProperties.js'; import { SET_KEYS } from '../../../src/domain/editing/properties/SetKeyCatalog.js'; import { EditSession } from '../../../src/domain/document/EditSession.js'; +import { typeRequest } from '../../../src/domain/document/utils/requests.js'; /* ------------------------------------------------------------------ */ /* fixtures */ @@ -452,8 +453,8 @@ describe('remove', () => { expect(ids(doc.outgoing(doc.require('Task_B')))).toEqual([]); expect(ids(doc.incoming(doc.require('Call')))).toEqual([]); expect(ids(doc.require('Lane_Ops').get('flowNodeRef'))).not.toContain('Sub'); - // a new id follows the file: its tasks are Task_* - expect(new EditSession(doc).newId('Activity', 'Sub')).toBe('Task_Sub'); + // a new id follows the conventions, not the file's Task_* ids + expect(new EditSession(doc).allocator.allocate(typeRequest('bpmn:Task', { name: 'Sub' })).id).toBe('task_sub'); // a gateway default is cleared when the default flow goes new ElementRemoval(new EditSession(doc)).remove({ op: 'remove', ids: ['F_no'] }); expect(doc.require('Gw').get('default')).toBeUndefined(); @@ -1344,10 +1345,10 @@ describe('move: bridging with --in, no id recycling', () => { ); const cs = new ElementMove(new EditSession(doc)).move({ op: 'move', ids: ['X'], after: 'B' }); expect(cs.removed.map((change) => change.id)).toEqual(['Flow_4']); - expect(cs.created.map((change) => change.id)).toEqual(['Flow_XToC']); + expect(cs.created.map((change) => change.id)).toEqual(['flow_xToC']); expect(doc.get('Flow_4')).toBeUndefined(); - expect(doc.require('Flow_XToC').get('sourceRef').get('id')).toBe('X'); - expect(doc.require('Flow_XToC').get('targetRef').get('id')).toBe('C'); + expect(doc.require('flow_xToC').get('sourceRef').get('id')).toBe('X'); + expect(doc.require('flow_xToC').get('targetRef').get('id')).toBe('C'); expect(doc.require('Flow_1').get('targetRef').get('id')).toBe('A'); await expectRoundTrip(doc); }); diff --git a/test/domain/editing/ops-usage.test.ts b/test/domain/editing/ops-usage.test.ts index 9fc20a3a..45aa3348 100644 --- a/test/domain/editing/ops-usage.test.ts +++ b/test/domain/editing/ops-usage.test.ts @@ -46,7 +46,7 @@ async function rejected(promise: Promise): Promise { describe('split: a join only where two branches reach it', () => { const start = [ - { op: 'add', kind: 'startEvent', name: 'Start', in: 'Process_Sj', as: '$s' }, + { op: 'add', kind: 'startEvent', name: 'Start', in: 'sj', as: '$s' }, { op: 'add', kind: 'task', name: 'Check', after: '$s', as: '$c' }, { op: 'add', kind: 'endEvent', name: 'Done', after: '$c' }, ]; @@ -54,23 +54,23 @@ describe('split: a join only where two branches reach it', () => { it('the other branch ends in an end event: no pass-through join, the branch runs on to the successor', async () => { const result = await applyToXml((await newXml({ processName: 'Sj' })).xml, [...start, { op: 'split', after: '$c', kind: 'exclusiveGateway', name: 'Ok?', branches: [{ flowName: 'yes', condition: '${ok}', nodes: [{ kind: 'task', name: 'Book' }] }, rejectBranch] }], { layout: false }); - expect(result.xml).not.toContain('Gateway_Ok_join'); - expect(result.xml).toContain(''); - expect(result.result.notes).toContain('only the branch ending at Activity_Book continues; no join gateway created (it runs on to Event_Done)'); - expect(result.result.notes).toContain('split Gateway_Ok: 2 branch(es) ending at Activity_Book, Event_Rejected (end event), continues to Event_Done'); + expect(result.xml).not.toContain('gateway_okJoin'); + expect(result.xml).toContain(''); + expect(result.result.notes).toContain('only the branch ending at task_book continues; no join gateway created (it runs on to endEvent_done)'); + expect(result.result.notes).toContain('split gateway_ok: 2 branch(es) ending at task_book, endEvent_rejected (end event), continues to endEvent_done'); expect(result.result.validation.errors).toEqual([]); }); it('a direct branch next to a terminating one: the gateway runs on to the successor', async () => { const result = await applyToXml((await newXml({ processName: 'Sj' })).xml, [...start, { op: 'split', after: '$c', kind: 'exclusiveGateway', name: 'Ok?', branches: [{ flowName: 'yes', condition: '${ok}', nodes: [] }, rejectBranch] }], { layout: false }); - expect(result.xml).not.toMatch(/_join/); - expect(result.xml).toMatch(//); + expect(result.xml).not.toContain('gateway_okJoin'); + expect(result.xml).toMatch(//); }); it('two continuing branches and a terminating one keep their join; joinAs with one continuing branch is refused', async () => { const result = await applyToXml((await newXml({ processName: 'Sj' })).xml, [...start, { op: 'split', after: '$c', kind: 'exclusiveGateway', name: 'Ok?', branches: [{ flowName: 'a', condition: '${a}', nodes: [{ kind: 'task', name: 'A' }] }, { flowName: 'b', condition: '${b}', nodes: [{ kind: 'task', name: 'B' }] }, rejectBranch] }], { layout: false }); - expect(result.xml).toContain('id="Gateway_Ok_join"'); - expect([...result.xml.matchAll(/targetRef="Gateway_Ok_join"/g)].length).toBe(2); + expect(result.xml).toContain('id="gateway_okJoin"'); + expect([...result.xml.matchAll(/targetRef="gateway_okJoin"/g)].length).toBe(2); const failure = await rejected(applyToXml((await newXml({ processName: 'Sj' })).xml, [...start, { op: 'split', after: '$c', joinAs: '$j', branches: [{ condition: '${ok}', nodes: [{ kind: 'task', name: 'Book' }] }, rejectBranch] }], { layout: false })); expect(failure.code).toBe('E_USAGE'); expect(failure.message).toContain('only one branch continues'); @@ -121,16 +121,16 @@ describe('space --by: columns, rows and pixels, the distance noted', () => { describe('E_NOT_FOUND: ids with the query prefix first', () => { it('a mistyped flow id suggests the flow, not the gateway its words contain', async () => { const result = await applyToXml((await newXml({ processName: 'Purchase' })).xml, [ - { op: 'add', kind: 'startEvent', name: 'Start', in: 'Process_Purchase', as: '$s' }, + { op: 'add', kind: 'startEvent', name: 'Start', in: 'purchase', as: '$s' }, { op: 'add', kind: 'userTask', name: 'Approve purchase', after: '$s', as: '$a' }, { op: 'split', after: '$a', name: 'Purchase approved?', branches: [{ flowName: 'yes', condition: '${ok}', nodes: [{ kind: 'task', name: 'Order goods' }] }, { flowName: 'no', default: true, nodes: [{ kind: 'endEvent', name: 'Rejected' }] }] }, ]); const doc = await Doc.fromXml(result.xml); - expect(doc.has('Flow_PurchaseApprovedToOrderGoods')).toBe(true); - expect(doc.suggest('Flow_GatewayPurchaseApprovedToOrderGoods')[0]).toBe('Flow_PurchaseApprovedToOrderGoods'); + expect(doc.has('flow_purchaseApprovedToOrderGoods')).toBe(true); + expect(doc.suggest('flow_gatewayPurchaseApprovedToOrderGoods')[0]).toBe('flow_purchaseApprovedToOrderGoods'); // a query with another prefix still finds the element - expect(doc.suggest('Task_ApprovePurchase')[0]).toBe('Activity_ApprovePurchase'); - expect(doc.suggest('Flow_ApprovePurchaseToPurchaseAproved')[0]).toBe('Flow_ApprovePurchaseToPurchaseApproved'); + expect(doc.suggest('task_approvePurchase')[0]).toBe('userTask_approvePurchase'); + expect(doc.suggest('flow_approvePurchaseToPurchaseAproved')[0]).toBe('flow_approvePurchaseToPurchaseApproved'); }); }); diff --git a/test/domain/editing/properties/decision-link.test.ts b/test/domain/editing/properties/decision-link.test.ts index 778dd655..e018bd27 100644 --- a/test/domain/editing/properties/decision-link.test.ts +++ b/test/domain/editing/properties/decision-link.test.ts @@ -162,7 +162,7 @@ describe('set calledDecision=', () => { it('works in `add` (trailing key=value / the set map of an add op)', async () => { const doc = await Doc.fromXml(model('', C7)); runOps(doc, [{ op: 'add', kind: 'businessRuleTask', name: 'Rate', after: 'R', set: { calledDecision: 'risk' } }]); - expect(doc.require('Activity_Rate').$attrs).toEqual({ 'camunda:decisionRef': 'risk' }); + expect(doc.require('businessRuleTask_rate').$attrs).toEqual({ 'camunda:decisionRef': 'risk' }); }); it('is a documented set key of business rule tasks (kinds --json)', async () => { @@ -333,7 +333,7 @@ describe('a model without an engine keeps the file\'s spelling (issue #42)', () it('`add` with calledDecision follows the file\'s spelling too', async () => { const doc = await Doc.fromXml(pair('camunda:decisionRef="d1"', '', { ns: CAMUNDA })); const result = await mutateDoc(doc, [{ op: 'add', kind: 'businessRuleTask', name: 'Price', after: 'Activity_Other', set: { calledDecision: 'price' } }], { layout: false, platform: 'none' }); - expect(result.xml).toContain(''); + expect(result.xml).toContain(''); }); it('an empty value still removes the link in every spelling', async () => { diff --git a/test/domain/editing/remove-branch.test.ts b/test/domain/editing/remove-branch.test.ts index 095cd9c1..bf9d90fb 100644 --- a/test/domain/editing/remove-branch.test.ts +++ b/test/domain/editing/remove-branch.test.ts @@ -153,9 +153,9 @@ describe('remove --bridge-all', () => { expect(outcome.changed).toEqual(['F5: B2 -> C (bridged J)', 'F7: D1 -> C (bridged J)']); expect(outcome.notes).toContain('bridged all: B2, D1 -> C'); expect(outcome.warnings).toContain('W_IMPLICIT_JOIN C'); - // a flow whose id names its ends in the file's style (F_B2ToJ: the file's flows are F) follows them - const named = await removed(SPLIT.replace(/"F5"/g, '"F_B2ToJ"').replace(/>F5F_B2ToJ<'), ['J'], { bridgeAll: true }); - expect(named.changed).toEqual(['F_B2ToC: B2 -> C (bridged J) (renamed from F_B2ToJ: its id named its old ends)', 'F7: D1 -> C (bridged J)']); + // a flow whose id names its ends (flow_b2ToJ) follows them + const named = await removed(SPLIT.replace(/"F5"/g, '"flow_b2ToJ"').replace(/>F5flow_b2ToJ<'), ['J'], { bridgeAll: true }); + expect(named.changed).toEqual(['flow_b2ToC: B2 -> C (bridged J) (renamed from flow_b2ToJ: its id named its old ends)', 'F7: D1 -> C (bridged J)']); }); it('a parallel join loses its synchronisation: the warning says so', async () => { diff --git a/test/domain/editing/semantic-regressions.test.ts b/test/domain/editing/semantic-regressions.test.ts index 6a21ee21..51ea5067 100644 --- a/test/domain/editing/semantic-regressions.test.ts +++ b/test/domain/editing/semantic-regressions.test.ts @@ -148,7 +148,7 @@ describe('trigger detail changes keep the event definition', () => { it('message: a new message keeps the definition id and its vendor attributes', async () => { const result = await mutate(EVENTS, [{ op: 'set', id: 'E', values: { message: 'Notification2' } }]); expect(result.changes.warnings).toEqual([]); - expect(result.xml).toContain(''); + expect(result.xml).toContain(''); }); it('conditional: a new condition keeps the definition and its expression element', async () => { @@ -178,7 +178,7 @@ describe('trigger detail changes keep the event definition', () => { it('retype with options of the same trigger keeps the definition too', async () => { const result = await mutate(EVENTS, [{ op: 'retype', id: 'E', kind: 'endEvent:message', message: 'Other' }]); - expect(result.xml).toContain(''); + expect(result.xml).toContain(''); }); it('a trigger kind change replaces the definition and reports the vendor content it drops', async () => { diff --git a/test/domain/validation/c7-profile.test.ts b/test/domain/validation/c7-profile.test.ts index 3daab362..adaa2654 100644 --- a/test/domain/validation/c7-profile.test.ts +++ b/test/domain/validation/c7-profile.test.ts @@ -364,10 +364,10 @@ describe('mutations report only the findings they introduce', () => { expect(added.validation.warnings.map((warning) => warning.code)).toContain('W_C7_DEPLOY_IMPLEMENTATION'); expect(added.validation.platform).toMatchObject({ platform: 'c7', counts: { deploy: 1 }, resolved: [] }); expect(added.validation.platform!.added!.map((finding) => finding.code)).toEqual(['W_C7_DEPLOY_IMPLEMENTATION']); - const next = await mutateDoc(doc, [{ op: 'set', id: 'Activity_Charge', values: { name: 'Charge card' } }], { dryRun: true, layout: false }); + const next = await mutateDoc(doc, [{ op: 'set', id: 'serviceTask_charge', values: { name: 'Charge card' } }], { dryRun: true, layout: false }); expect(next.validation.warnings.map((warning) => warning.code)).not.toContain('W_C7_DEPLOY_IMPLEMENTATION'); expect(next.validation.platform).toMatchObject({ counts: { deploy: 1 }, added: [], resolved: [] }); - const fixed = await mutateDoc(doc, [{ op: 'set', id: 'Activity_Charge', values: { 'camunda:type': 'external', 'camunda:topic': 'charge' } }], { dryRun: true, layout: false }); + const fixed = await mutateDoc(doc, [{ op: 'set', id: 'serviceTask_charge', values: { 'camunda:type': 'external', 'camunda:topic': 'charge' } }], { dryRun: true, layout: false }); expect(fixed.validation.platform!.resolved!.map((finding) => finding.code)).toEqual(['W_C7_DEPLOY_IMPLEMENTATION']); expect(fixed.validation.platform!.counts.deploy).toBe(0); }); diff --git a/test/domain/validation/c8-feel-and-deploy.test.ts b/test/domain/validation/c8-feel-and-deploy.test.ts index 7decddd7..02e4de65 100644 --- a/test/domain/validation/c8-feel-and-deploy.test.ts +++ b/test/domain/validation/c8-feel-and-deploy.test.ts @@ -261,33 +261,33 @@ describe('the message a catching element waits for', () => { it('refAs names the message an op creates; the subscription goes on it (no misplaced extension, no deploy finding)', async () => { const mutated = await mutateDoc(blank(), [ - { op: 'add', kind: 'startEvent', name: 'Asked', in: 'Process_Reply', as: '$s' }, + { op: 'add', kind: 'startEvent', name: 'Asked', in: 'reply', as: '$s' }, { op: 'add', kind: 'intermediateCatchEvent:message', name: 'Antwort erhalten', after: '$s', message: 'Antwort', as: '$m', refAs: '$msg' }, { op: 'ext', id: '$msg', action: 'add', type: 'zeebe:subscription', attrs: { correlationKey: '= id' } }, { op: 'add', kind: 'endEvent', name: 'Done', after: '$m' }, ]); - expect(mutated.aliases).toEqual({ $s: 'Event_Asked', $m: 'Event_AntwortErhalten', $msg: 'Message_Antwort' }); - expect(mutated.xml).toMatch(/\s*\s*/); + expect(mutated.aliases).toEqual({ $s: 'startEvent_asked', $m: 'event_antwortErhalten', $msg: 'message_antwort' }); + expect(mutated.xml).toMatch(/\s*\s*/); expect(runProfile(await Doc.fromXml(mutated.xml)).findings).toEqual([]); }); it('refAs on set, retype and connect (a message flow); E_USAGE when the element references none', async () => { const doc = blank(); runOps(doc, [ - { op: 'add', kind: 'startEvent', name: 'Go', in: 'Process_Reply' }, - { op: 'add', kind: 'receiveTask', name: 'Wait', after: 'Event_Go' }, - { op: 'add', kind: 'intermediateThrowEvent', name: 'Raise', after: 'Activity_Wait' }, - { op: 'add', kind: 'endEvent', name: 'Done', after: 'Event_Raise' }, + { op: 'add', kind: 'startEvent', name: 'Go', in: 'reply' }, + { op: 'add', kind: 'receiveTask', name: 'Wait', after: 'startEvent_go' }, + { op: 'add', kind: 'intermediateThrowEvent', name: 'Raise', after: 'receiveTask_wait' }, + { op: 'add', kind: 'endEvent', name: 'Done', after: 'event_raise' }, ]); const mutated = await mutateDoc(doc, [ - { op: 'set', id: 'Activity_Wait', values: { message: 'Paid' }, refAs: '$paid' }, - { op: 'retype', id: 'Event_Raise', kind: 'intermediateThrowEvent:signal', signal: 'Raised', refAs: '$sig' }, + { op: 'set', id: 'receiveTask_wait', values: { message: 'Paid' }, refAs: '$paid' }, + { op: 'retype', id: 'event_raise', kind: 'intermediateThrowEvent:signal', signal: 'Raised', refAs: '$sig' }, { op: 'ext', id: '$paid', action: 'add', type: 'zeebe:subscription', attrs: { correlationKey: '=orderId' } }, ]); - expect(mutated.aliases).toEqual({ $paid: 'Message_Paid', $sig: 'Signal_Raised' }); - const caughtError = caught(() => runOps(blank(), [{ op: 'add', kind: 'userTask', name: 'Review', in: 'Process_Reply', refAs: '$x' }])); + expect(mutated.aliases).toEqual({ $paid: 'message_paid', $sig: 'signal_raised' }); + const caughtError = caught(() => runOps(blank(), [{ op: 'add', kind: 'userTask', name: 'Review', in: 'reply', refAs: '$x' }])); expect(caughtError.code).toBe('E_USAGE'); - expect(caughtError.message).toBe('refAs $x: userTask Activity_Review references no message, error, signal or escalation'); + expect(caughtError.message).toBe('refAs $x: userTask userTask_review references no message, error, signal or escalation'); }); it('ext add zeebe:subscription and set zeebe:correlationKey= go to its message, with a note', async () => { @@ -366,14 +366,14 @@ describe.skipIf(!ENGINE)('live engine: the verdicts hold', () => { it('the batch with refAs deploys', async () => { const mutated = await mutateDoc(Doc.create({ target: 'camunda8', processName: 'Reply' }), [ - { op: 'add', kind: 'startEvent', name: 'Asked', in: 'Process_Reply', as: '$s' }, + { op: 'add', kind: 'startEvent', name: 'Asked', in: 'reply', as: '$s' }, { op: 'add', kind: 'intermediateCatchEvent:message', name: 'Antwort erhalten', after: '$s', message: 'Antwort', as: '$m', refAs: '$msg' }, { op: 'ext', id: '$msg', action: 'add', type: 'zeebe:subscription', attrs: { correlationKey: '= id' } }, { op: 'add', kind: 'receiveTask', name: 'Wait for confirmation', after: '$m', message: 'Confirmation', as: '$r' }, { op: 'ext', id: '$r', action: 'add', type: 'zeebe:subscription', attrs: { correlationKey: '= id' } }, { op: 'add', kind: 'endEvent', name: 'Done', after: '$r' }, ]); - const error = await deploy('P_bpmn_cli_refas', mutated.xml.replace(/Process_Reply/g, 'P_bpmn_cli_refas')); + const error = await deploy('P_bpmn_cli_refas', mutated.xml.replace(/"reply"/g, '"P_bpmn_cli_refas"')); expect(error, error).toBeUndefined(); }); }); diff --git a/test/domain/validation/c8-profile.test.ts b/test/domain/validation/c8-profile.test.ts index 0e58a694..f7de9981 100644 --- a/test/domain/validation/c8-profile.test.ts +++ b/test/domain/validation/c8-profile.test.ts @@ -429,18 +429,18 @@ describe('Camunda 8 files the CLI writes', () => { doc, [ { op: 'add', kind: 'start', name: 'Go' }, - { op: 'add', kind: 'userTask', name: 'Review', after: 'Event_Go' }, - { op: 'add', kind: 'task', name: 'Ship', after: 'Activity_Review' }, - { op: 'retype', id: 'Activity_Ship', kind: 'userTask' }, + { op: 'add', kind: 'userTask', name: 'Review', after: 'startEvent_go' }, + { op: 'add', kind: 'task', name: 'Ship', after: 'userTask_review' }, + { op: 'retype', id: 'task_ship', kind: 'userTask' }, ], { dryRun: true }, ); - expect(result.xml).toMatch(/\s*\s*/); - expect(result.xml).toMatch(/\s*\s*/); - expect(result.changes.notes.join('\n')).toMatch(/Activity_Review is a Camunda user task \(zeebe:userTask\)/); + expect(result.xml).toMatch(/\s*\s*/); + expect(result.xml).toMatch(/\s*\s*/); + expect(result.changes.notes.join('\n')).toMatch(/userTask_review is a Camunda user task \(zeebe:userTask\)/); expect(result.validation.warnings.map((warning) => warning.code)).not.toContain('W_C8_JOB_WORKER_USER_TASK'); for (const target of [undefined, 'camunda7'] as const) { - const other = await mutateDoc(Doc.create({ target, processName: 'Order' }), [{ op: 'add', kind: 'userTask', name: 'Review', in: 'Process_Order' }], { dryRun: true }); + const other = await mutateDoc(Doc.create({ target, processName: 'Order' }), [{ op: 'add', kind: 'userTask', name: 'Review', in: 'order' }], { dryRun: true }); expect(other.xml).not.toContain('zeebe:userTask'); } }); @@ -451,12 +451,12 @@ describe('Camunda 8 files the CLI writes', () => { doc, [ { op: 'add', kind: 'start', name: 'Go' }, - { op: 'add', kind: 'intermediateCatchEvent:conditional', name: 'Stock ready', after: 'Event_Go', when: '= stock > 0' }, - { op: 'add', kind: 'end', name: 'Done', after: 'Event_StockReady' }, + { op: 'add', kind: 'intermediateCatchEvent:conditional', name: 'Stock ready', after: 'startEvent_go', when: '= stock > 0' }, + { op: 'add', kind: 'end', name: 'Done', after: 'event_stockReady' }, ], { dryRun: true }, ); - expect(result.xml).toContain(''); + expect(result.xml).toContain(''); expect(result.validation.platform).toMatchObject({ platform: 'c8', counts: { deploy: 0, runtime: 0, practice: 0 } }); const plain = await mutateDoc(Doc.create({ processName: 'Order' }), [{ op: 'add', kind: 'start', name: 'Go', timer: 'PT1H' }], { dryRun: true }); expect(plain.xml).toMatch(//); diff --git a/test/domain/validation/conventions-declared.test.ts b/test/domain/validation/conventions-declared.test.ts index 73169de7..d41cb02b 100644 --- a/test/domain/validation/conventions-declared.test.ts +++ b/test/domain/validation/conventions-declared.test.ts @@ -1,15 +1,15 @@ /** * Declared id conventions in the core (issue #34; src/domain/document/ids/IdStyle.ts DECLARED * CONVENTIONS): they fill the gaps a change opens and never rename what is - * there; the rest of the file keeps its own style, stable over many writes; - * W_ID_PREFIX_STALE after a retype or a trigger change; the `conventions` - * of a result; the view of `bpmn show --conventions`. The conventions come + * there; the rest takes element-id-naming's defaults or bpmn-cli's own prefix, + * whatever ids the file already has; W_ID_PREFIX_STALE after a retype or a trigger + * change; the view of `bpmn show --conventions`. The conventions come * from the real plugin's naming helpers (src/adapters/out/bpmnlint/IdConventionsReader.ts). */ import { describe, expect, it } from 'vitest'; import { applyToXml, conventionsXml } from '../../../src/api.js'; import type { BpmnlintRuleSetting } from '../../../src/domain/document/ids/bpmnlint-rule-setting.js'; -import { conventionsView, idConventionsFromLint, renderConventions, type IdConventions, mutateDoc, mutationReport, mutationSummary, renderMutation, renderSummary } from '../../../src/index.js'; +import { conventionsView, idConventionsFromLint, renderConventions, type IdConventions, mutateDoc, mutationReport, mutationSummary } from '../../../src/index.js'; import { Doc } from '../../../src/domain/document/Doc.js'; import { bpmnlintEngine } from '../../../src/node.js'; import type { EditCommand } from '../../../src/domain/commands/types.js'; @@ -53,34 +53,16 @@ const CONFORMING = definitionsXml(` `); describe('new ids take the declared prefix and case', () => { - it('a kind the rule names takes its prefix; the rest of the file keeps its own style', async () => { + it('a kind the rule names takes its prefix, also in a file of another style', async () => { const result = await run(PASCAL, [ { op: 'add', kind: 'serviceTask', name: 'Book order', after: 'Activity_CheckOrder' }, { op: 'add', kind: 'lane', name: 'Warehouse', in: 'Process_1' }, { op: 'add', kind: 'dataObject', name: 'Delivery note', in: 'Process_1' }, ]); - expect(createdIds(result)).toEqual(['serviceTask_bookOrder', 'flow_bookOrderToShipOrder', 'Lane_Warehouse', 'DataObjectReference_DeliveryNote']); - expect(result.xml).toContain(''); - // the spliced flow named its ends: renamed after its new ones, in the declared form - expect(result.changes.renames).toEqual([expect.objectContaining({ from: 'Flow_CheckOrderToShipOrder', to: 'flow_checkOrderToBookOrder' })]); - // the ids the conventions made other than the file's style would - expect(result.conventions).toEqual({ ids: ['serviceTask_bookOrder', 'flow_checkOrderToBookOrder', 'flow_bookOrderToShipOrder'], source: '.bpmnlintrc in .' }); - }); - - it('the ids bpmn-cli writes from the table never vote on the rest: lanes and data objects stay PascalCase over many writes', async () => { - let xml = PASCAL; - const lanes: string[] = []; - for (const team of ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']) { - const result = await run(xml, [ - { op: 'add', kind: 'task', name: `Check ${team}`, after: 'Activity_CheckOrder' }, - { op: 'add', kind: 'userTask', name: `Approve ${team}`, in: 'Process_1' }, - { op: 'add', kind: 'lane', name: `Team ${team}`, in: 'Process_1' }, - { op: 'add', kind: 'dataObject', name: `Doc ${team}`, in: 'Process_1' }, - ]); - lanes.push(...createdIds(result).filter((id) => /^(Lane|DataObjectReference)_/.test(id) || id.startsWith('lane'))); - xml = result.xml; - } - expect(lanes).toEqual(['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'].flatMap((letter) => [`Lane_Team${letter}`, `DataObjectReference_Doc${letter}`])); + expect(createdIds(result)).toEqual(['serviceTask_bookOrder', 'flow_bookOrderToShipOrder', 'lane_warehouse', 'dataObject_deliveryNote']); + expect(result.xml).toContain(''); + // the spliced flow is not of the declared form: it keeps its id + expect(result.changes.renames).toEqual([]); }); it('a new file: what the rule does not name takes its case and prefix shape, stable over many writes', async () => { @@ -98,17 +80,7 @@ describe('new ids take the declared prefix and case', () => { expect(ids.filter((id) => id.startsWith('task_'))).toEqual(['A', 'B', 'C', 'D', 'E', 'F'].map((letter) => `task_work${letter}`)); }); - it('what the rule does not name keeps the file\'s style: its own ids vote unless they already follow the rule (one Lane_, hashes, only covered ids)', async () => { - // one lane: the Activity_ / Event_ ids the rule does not accept vote too - const oneLane = definitionsXml(` - - - - - `); - const lane = await run(oneLane, [{ op: 'add', kind: 'lane', name: 'Back office', in: 'Process_1' }]); - expect(createdIds(lane)).toEqual(['Lane_BackOffice']); - // hashes show no case: prefixes of the other first letter than the rule's keep bpmn-cli's PascalCase + it('pools, lanes, data and annotations take the rule\'s default prefixes in a file of hashes too; backing data the type name', async () => { const hashes = definitionsXml(` @@ -119,24 +91,11 @@ describe('new ids take the declared prefix and case', () => { { op: 'add', kind: 'textAnnotation', text: 'Be quick', in: 'Process_1' }, { op: 'add', kind: 'lane', name: 'Back office', in: 'Process_1' }, ]); - expect(createdIds(pools)).toEqual(['Collaboration_Process', 'Participant_Customer', 'DataStoreReference_Archive', 'TextAnnotation_BeQuick', 'Lane_BackOffice']); - // a file of PascalCase ids of types the rule names - const covered = definitionsXml(` - - - - `); - const rest = await run(covered, [ - { op: 'add', kind: 'participant', name: 'Customer' }, - { op: 'add', kind: 'lane', name: 'Sales', in: 'Process_1' }, - { op: 'add', kind: 'dataObject', name: 'Invoice copy', in: 'Process_1' }, - ]); - expect(createdIds(rest)).toEqual(['Collaboration_Process', 'Participant_Customer', 'Lane_Sales', 'DataObjectReference_InvoiceCopy']); - // none of them follows the rule: no conventions line - for (const outcome of [lane, pools, rest]) expect(outcome.conventions).toBeUndefined(); + expect(createdIds(pools)).toEqual(['collaboration_process', 'participant_customer', 'dataStore_archive', 'textAnnotation_beQuick', 'lane_backOffice']); + expect(pools.xml).toContain(' { + it('a new file\'s lanes take the declared case over many writes, also in a file of other lanes', async () => { for (const [conventions, expected] of [ [BARE, ['lane_sales', 'lane_invoice', 'lane_backOffice', 'lane_orderArchive']], [declared({ case: 'snake_case' }), ['lane_sales', 'lane_invoice', 'lane_back_office', 'lane_order_archive']], @@ -152,12 +111,11 @@ describe('new ids take the declared prefix and case', () => { } const snake = definitionsXml(` `); - expect(createdIds(await run(snake, [{ op: 'add', kind: 'lane', name: 'Shared services', in: 'Process_1' }]))).toEqual(['lane_shared_services']); - // they are no PascalCase, though: under a declared PascalCase they read as camel, like bare ids + expect(createdIds(await run(snake, [{ op: 'add', kind: 'lane', name: 'Shared services', in: 'Process_1' }]))).toEqual(['lane_sharedServices']); const lower = definitionsXml(` `); - expect(createdIds(await run(lower, [{ op: 'add', kind: 'lane', name: 'Back office', in: 'Process_1' }, { op: 'add', kind: 'task', name: 'Ship goods', in: 'Process_1' }], declared({ case: 'PascalCase' })))).toEqual(['lane_backOffice', 'task_ShipGoods']); + expect(createdIds(await run(lower, [{ op: 'add', kind: 'lane', name: 'Back office', in: 'Process_1' }, { op: 'add', kind: 'task', name: 'Ship goods', in: 'Process_1' }], declared({ case: 'PascalCase' })))).toEqual(['lane_BackOffice', 'task_ShipGoods']); }); it('the event-definition qualifier: plain under optional (also in a file that qualifies), qualified under required', async () => { @@ -254,10 +212,9 @@ describe('new ids take the declared prefix and case', () => { // an explicit split id the declared prefix does not start: the join takes the prefix and the gateway's label const result = await run(bare, split({ id: 'Gateway_Check' }), declared({ case: 'snake_case' })); expect(createdIds(result)).toEqual(expect.arrayContaining(['Gateway_Check', 'gateway_check_join'])); - expect(result.conventions?.ids).toContain('gateway_check_join'); expect(await joins(bare, split({ id: 'Gateway_Check' }), BARE)).toEqual(['gateway_checkJoin']); expect(await joins(bare, split({ id: 'Gateway_0abc123', name: 'Invoice ok?' }), BARE)).toEqual(['gateway_invoiceOkJoin']); - // one the prefix starts keeps Join; without declared conventions the file's join + // one the prefix starts keeps Join expect(await joins(bare, split({ id: 'gateway_check' }), BARE)).toEqual(['gateway_checkJoin']); // one the prefix starts with a body in another case than the declared one: the prefix and the words of that body expect(await joins(bare, split({ id: 'gateway_Approved' }), BARE)).toEqual(['gateway_approvedJoin']); @@ -266,12 +223,12 @@ describe('new ids take the declared prefix and case', () => { expect(await joins(bare, split({ id: 'gateway_approved' }), declared({ case: 'PascalCase' }))).toEqual(['gateway_ApprovedJoin']); expect(await joins(bare, split({ id: 'gateway_Approved' }), declared({ case: 'snake_case' }))).toEqual(['gateway_approved_join']); expect(await joins(bare, split({ id: 'gwApproved' }), declared({ prefixes: { 'bpmn:Gateway': 'gw' } }))).toEqual(['gwapprovedJoin']); - // `any` checks no case: the file's join after the id as it is - expect(await joins(bare, split({ id: 'gateway_Approved' }), declared({ case: 'any' }))).toEqual(['gateway_Approved_join']); - expect(createdIds(await run(bare, split({ id: 'Gateway_Check' }), null))).toContain('Gateway_Check_join'); + // `any` checks no case: Join after the id as it is; without declared conventions the defaults' join + expect(await joins(bare, split({ id: 'gateway_Approved' }), declared({ case: 'any' }))).toEqual(['gateway_ApprovedJoin']); + expect(createdIds(await run(bare, split({ id: 'Gateway_Check' }), null))).toContain('gateway_checkJoin'); }); - it('flows: the named form with the declared prefix, the ends without the file\'s scope, message flows like sequence flows', async () => { + it('flows: the named form with the declared prefix, the ends after the first `_` of their ids, message flows with their own prefix', async () => { const scoped = definitionsXml(` Flow_KotO_StartToCheckInvoice Flow_KotO_StartToCheckInvoiceFlow_KotO_CheckInvoiceToBook @@ -279,24 +236,24 @@ describe('new ids take the declared prefix and case', () => { `); const result = await run(scoped, [{ op: 'add', kind: 'task', name: 'Archive', after: 'Activity_KotO_Book' }]); - expect(createdIds(result)).toEqual(['task_archive', 'flow_bookToArchive']); + expect(createdIds(result)).toEqual(['task_archive', 'flow_kotOBookToArchive']); const pools = await run(definitionsXml(''), [ { op: 'add', kind: 'participant', name: 'Supplier' }, { op: 'add', kind: 'participant', name: 'Customer', as: '$c' }, { op: 'connect', source: 'Activity_A', target: '$c' }, ]); - expect(createdIds(pools).at(-1)).toBe('flow_sendOfferToCustomer'); - expect(createdIds(await run(definitionsXml(''), [{ op: 'add', kind: 'participant', name: 'Supplier' }, { op: 'add', kind: 'participant', name: 'Customer', as: '$c' }, { op: 'connect', source: 'Activity_A', target: '$c' }], declared({ prefixes: { 'bpmn:MessageFlow': 'messageFlow_' } }))).at(-1)).toBe('messageFlow_sendOfferToCustomer'); + expect(createdIds(pools).at(-1)).toBe('messageFlow_sendOfferToCustomer'); + expect(createdIds(await run(definitionsXml(''), [{ op: 'add', kind: 'participant', name: 'Supplier' }, { op: 'add', kind: 'participant', name: 'Customer', as: '$c' }, { op: 'connect', source: 'Activity_A', target: '$c' }], declared({ prefixes: { 'bpmn:MessageFlow': 'mf_' } }))).at(-1)).toBe('mf_sendOfferToCustomer'); }); - it('message flows keep the borrowed prefix and case over many writes (one message flow of the file is no style of its own)', async () => { + it('message flows keep their prefix and the declared case over many writes', async () => { const pools = [ { op: 'add', kind: 'participant', name: 'Main' }, { op: 'add', kind: 'participant', name: 'Customer' }, ]; - for (const [conventions, expected] of [ - [BARE, ['flow_sendOneToCustomer', 'flow_sendTwoToCustomer', 'flow_sendThreeToCustomer']], - [declared({ case: 'snake_case' }), ['flow_send_one_to_customer', 'flow_send_two_to_customer', 'flow_send_three_to_customer']], + for (const [conventions, expected, pattern] of [ + [BARE, ['messageFlow_sendOneToCustomer', 'messageFlow_sendTwoToCustomer', 'messageFlow_sendThreeToCustomer'], 'messageFlow_{source}To{target}'], + [declared({ case: 'snake_case' }), ['messageFlow_send_one_to_customer', 'messageFlow_send_two_to_customer', 'messageFlow_send_three_to_customer'], 'messageFlow_{source}_to_{target}'], ] as const) { let xml = PASCAL; const flows: string[] = []; @@ -304,28 +261,17 @@ describe('new ids take the declared prefix and case', () => { const result = await run(xml, [ ...(wordIndex ? [] : pools), { op: 'add', kind: 'intermediateThrowEvent:message', name: `Send ${word}`, in: 'Process_1', message: word, as: '$e' }, - { op: 'connect', source: '$e', target: 'Participant_Customer' }, + { op: 'connect', source: '$e', target: 'participant_customer' }, ], conventions); flows.push(createdIds(result).at(-1)!); xml = result.xml; } expect(flows).toEqual(expected); - // the view still names the borrowed prefix - expect(conventionsView(await Doc.fromXml(xml), conventions).rows.find((row) => row.kinds.includes('messageFlow'))).toMatchObject({ pattern: 'flow_{source}To{target}', source: 'lint shape' }); + expect(conventionsView(await Doc.fromXml(xml), conventions).rows.find((row) => row.kinds.includes('messageFlow'))).toEqual({ pattern, source: 'lint', kinds: ['messageFlow'] }); } - // a file's own message flows that agree (two) are a style of their own: new ones follow it - const own = definitionsXml(''); - const two = await run(own, [ - { op: 'add', kind: 'participant', name: 'Main' }, - { op: 'add', kind: 'participant', name: 'Customer', id: 'Participant_Customer' }, - { op: 'connect', source: 'Activity_A', target: 'Participant_Customer', id: 'MessageFlow_Offer' }, - { op: 'connect', source: 'Activity_B', target: 'Participant_Customer', id: 'MessageFlow_Bill' }, - ]); - const third = await run(two.xml, [{ op: 'connect', source: 'Activity_C', target: 'Participant_Customer' }]); - expect(createdIds(third)).toEqual(['MessageFlow_SendReminderToCustomer']); }); - it('the process of a new pool is bpmn-cli\'s Process_, over many writes in a file of lint-shaped pools', async () => { + it('the process of a new pool takes its name alone, over many writes', async () => { let doc = Doc.create({ processName: 'Order', conventions: BARE }); const processes: string[] = []; for (const name of ['Main', 'Customer', 'Supplier', 'Back office', 'Tax office']) { @@ -333,69 +279,34 @@ describe('new ids take the declared prefix and case', () => { processes.push(...result.changes.created.filter((created) => created.kind === 'process').map((created) => created.id)); doc = await Doc.fromXml(result.xml); } - expect(processes).toEqual(['Process_Customer', 'Process_Supplier', 'Process_BackOffice', 'Process_TaxOffice']); - expect(doc.processes().map((processElement) => processElement.get('id'))).toEqual(['Process_Order', ...processes]); + expect(processes).toEqual(['customer', 'supplier', 'backOffice', 'taxOffice']); + expect(doc.processes().map((processElement) => processElement.get('id'))).toEqual(['order', ...processes]); }); - it('a new pool\'s process follows the file\'s own process ids where two share a prefix, with and without the rule; never the participants\'', async () => { - const pools = (procs: [string, string], part: string, task: string): string => ` + it('a new pool\'s process takes its name in the declared case whatever the file\'s process ids; an exact entry for bpmn:Process decides', async () => { + const pools = (procs: [string, string]): string => ` - - + + - + `; - const newProcess = async (xml: string, conventions: IdConventions | null): Promise => { + const pool = async (xml: string, conventions: IdConventions | null): Promise => { const result = await run(xml, [{ op: 'add', kind: 'participant', name: 'Bank account' }], conventions); - return result.changes.created.find((created) => created.kind === 'process')!.id; - }; - // the prefix the processes share, the file's case (single lower-case words show none), whatever the pools' prefixes - for (const part of ['participant_', 'Participant_', 'Pool_']) { - const xml = pools(['process_customer', 'process_shop'], part, 'userTask_checkOrder'); - expect([await newProcess(xml, BARE), await newProcess(xml, null)], part).toEqual(['process_bankAccount', 'process_bankAccount']); - } - // in a snake_case file: snake without the rule and under a snake_case one; under camelCase the case of the pool - // made with it (participant_bankAccount: the snake ids under the rule's prefixes do not vote) - const snakeFile = pools(['process_customer', 'process_shop'], 'participant_', 'userTask_check_order').replace('userTask_shipOrder', 'userTask_ship_order'); - expect([await newProcess(snakeFile, null), await newProcess(snakeFile, declared({ case: 'snake_case' })), await newProcess(snakeFile, BARE)]).toEqual(['process_bank_account', 'process_bank_account', 'process_bankAccount']); - const snakePool = await run(snakeFile, [{ op: 'add', kind: 'participant', name: 'Bank account' }]); - expect(snakePool.changes.created.find((created) => created.kind === 'participant')?.id).toBe('participant_bankAccount'); - // bodies that show a case: theirs; a tie between prefixes: the alphabetically first - expect(await newProcess(pools(['Process_Customer', 'Process_Shop'], 'participant_', 'userTask_checkOrder'), BARE)).toBe('Process_BankAccount'); - const four = pools(['proc_customer', 'proc_shop'], 'participant_', 'userTask_checkOrder').replace('', ''); - expect(await newProcess(four, BARE)).toBe('flow_bankAccount'); - // processes that share no prefix: bpmn-cli's Process_ under the rule (never the participants' participant_); - // without it the file's prefix shape, as before - const apart = pools(['proc_customer', 'Process_shop'], 'participant_', 'userTask_checkOrder'); - expect([await newProcess(apart, BARE), await newProcess(apart, null)]).toEqual(['Process_BankAccount', 'process_bankAccount']); - // the view: the process row comes from the file (one row with the pools: _ is process_ for a process) - const view = conventionsView(await Doc.fromXml(pools(['process_customer', 'process_shop'], 'participant_', 'userTask_checkOrder')), BARE); - expect(view.rows.find((row) => row.kinds.includes('process'))).toMatchObject({ pattern: '_{name:camel}', source: 'file', note: 'not checked', kinds: expect.arrayContaining(['participant', 'process']) }); - expect(conventionsView(await Doc.fromXml(apart), BARE).rows.find((row) => row.kinds.includes('process'))).toMatchObject({ pattern: 'Process_{name:pascal}', source: 'default' }); - // an exact entry for bpmn:Process still decides - expect(await newProcess(pools(['proc_customer', 'proc_shop'], 'participant_', 'userTask_checkOrder'), declared({ prefixes: { 'bpmn:Process': 'process_' } }))).toBe('process_bankAccount'); - // bodies that tie between two cases: the file's case (camelCase here), with and without the rule - const tie = pools(['process_Customer', 'process_shopFloor'], 'participant_', 'userTask_checkOrder'); - expect([await newProcess(tie, BARE), await newProcess(tie, null)]).toEqual(['process_bankAccount', 'process_bankAccount']); - // bodies that show no case (Camunda Modeler's Process_1 and a hash): the case of a kind the rule does not name, so - // the process matches the pool made with it (under the rule bpmn-cli's PascalCase next to hashed participants) - const modeler = ` - - - - - - - -`; - const pool = async (conventions: IdConventions | null): Promise => { - const result = await run(modeler, [{ op: 'add', kind: 'participant', name: 'Bank account' }], conventions); return result.changes.created.filter((created) => created.kind === 'process' || created.kind === 'participant').map((created) => created.id); }; - expect(await pool(BARE)).toEqual(['Process_BankAccount', 'Participant_BankAccount']); - expect(await pool(null)).toEqual(['Process_bankAccount', 'Participant_bankAccount']); + for (const procs of [['process_customer', 'process_shop'], ['Process_1', 'Process_0k3x9qa']] as Array<[string, string]>) { + const xml = pools(procs); + expect([await pool(xml, BARE), await pool(xml, null)], procs[0]).toEqual([['bankAccount', 'participant_bankAccount'], ['bankAccount', 'participant_bankAccount']]); + expect(await pool(xml, declared({ case: 'snake_case' })), procs[0]).toEqual(['bank_account', 'participant_bank_account']); + expect(await pool(xml, declared({ prefixes: { 'bpmn:Process': 'process_' } })), procs[0]).toEqual(['process_bankAccount', 'participant_bankAccount']); + } + // the view: the process row is bpmn-cli's, or the declared prefix + const xml = pools(['process_customer', 'process_shop']); + expect(conventionsView(await Doc.fromXml(xml), BARE).rows.find((row) => row.kinds.includes('process'))).toEqual({ pattern: '{name:camel}', source: 'bpmn-cli', kinds: ['process'], note: 'not checked; unnamed: Process_1' }); + expect(conventionsView(await Doc.fromXml(xml), declared({ prefixes: { 'bpmn:Process': 'process_' } })).rows.find((row) => row.kinds.includes('process'))).toMatchObject({ pattern: '_{name:camel}', source: 'lint' }); }); it('explicit ids are taken as given; the process and the definitions keep theirs', async () => { @@ -407,94 +318,34 @@ describe('new ids take the declared prefix and case', () => { expect(result.xml).toContain(' { - const ops = [{ op: 'add', kind: 'task', name: 'Pack order', flow: 'Flow_CheckOrderToShipOrder' }]; - const renamed = await run(PASCAL, ops); - expect(renamed.changes.renames.map((rename) => `${rename.from} -> ${rename.to}`)).toEqual(['Flow_CheckOrderToShipOrder -> flow_checkOrderToPackOrder']); - const kept = await run(PASCAL, ops, BARE, { keepFlowIds: true }); + it('a flow of the declared form still names its ends: renamed after its new ones, or kept with keepFlowIds and named stale; a flow of another form keeps its id', async () => { + const ops = [{ op: 'add', kind: 'task', name: 'Pack order', flow: 'flow_checkOrderToShipOrder' }]; + const renamed = await run(CONFORMING, ops); + expect(renamed.changes.renames.map((rename) => `${rename.from} -> ${rename.to}`)).toEqual(['flow_checkOrderToShipOrder -> flow_checkOrderToPackOrder']); + const kept = await run(CONFORMING, ops, BARE, { keepFlowIds: true }); expect(kept.changes.renames).toEqual([]); - expect(kept.changes.warnings.find((warning) => warning.code === 'W_FLOW_ID_STALE')?.hint).toContain('`bpmn set Flow_CheckOrderToShipOrder id=flow_checkOrderToPackOrder`'); + expect(kept.changes.warnings.find((warning) => warning.code === 'W_FLOW_ID_STALE')?.hint).toContain('`bpmn set flow_checkOrderToShipOrder id=flow_checkOrderToPackOrder`'); + const other = await run(PASCAL, [{ op: 'add', kind: 'task', name: 'Pack order', flow: 'Flow_CheckOrderToShipOrder' }]); + expect(other.changes.renames).toEqual([]); + expect(other.changes.warnings.filter((warning) => warning.code === 'W_FLOW_ID_STALE')).toEqual([]); }); - it('a flow earlier versions named in a file of another flow form (#31) follows its ends too: into the declared form, or the file\'s where no flow prefix is declared', async () => { - // an idSnake file with a flow 0.6 named (Flow_pay_to_done) - const tasks = ['check', 'ship', 'bill', 'pay', 'done']; - const flows = ['Flow_Task_check_to_Task_ship', 'Flow_Task_ship_to_Task_bill', 'Flow_Task_bill_to_Task_pay', 'Flow_pay_to_done']; - const xml = definitionsXml( - tasks.map((task, taskIndex) => `${taskIndex ? `${flows[taskIndex - 1]}` : ''}${taskIndex < flows.length ? `${flows[taskIndex]}` : ''}`).join('\n') + - flows.map((flow, flowIndex) => ``).join('\n'), - ); - const ops = [{ op: 'add', kind: 'task', name: 'Log', flow: 'Flow_pay_to_done' }]; - const renames = (mutation: MutationResult): string[] => mutation.changes.renames.map((rename) => `${rename.from} -> ${rename.to}`); - expect(renames(await run(xml, ops, null))).toEqual(['Flow_pay_to_done -> Flow_Task_pay_to_Task_log']); - const lint = await run(xml, ops); - expect(createdIds(lint)).toEqual(['task_log', 'flow_logToDone']); - expect(renames(lint)).toEqual(['Flow_pay_to_done -> flow_payToLog']); - expect(renames(await run(xml, ops, { prefixes: { 'bpmn:Task': 'task_' }, case: 'camelCase' }))).toEqual(['Flow_pay_to_done -> Flow_Task_pay_to_task_log']); - }); - - it('a host passes its own table (no plugin); unusable prefixes are left out; none declared: the file\'s style', async () => { + it('a host passes its own table (no plugin); unusable prefixes are left out; a type without one takes its type name', async () => { const host: IdConventions = { prefixes: { 'bpmn:ServiceTask': 'svc_', 'bpmn:SequenceFlow': 'sf_', 'bpmn:UserTask': 'user task_' }, case: 'camelCase' }; const result = await applyToXml(PASCAL, [{ op: 'add', kind: 'serviceTask', name: 'Book order', after: 'Activity_CheckOrder' }, { op: 'add', kind: 'userTask', name: 'Approve', after: 'Activity_ShipOrder' }], { layout: false, conventions: host }); - expect(result.result.created.map((created) => created.id)).toEqual(['svc_bookOrder', 'sf_bookOrderToShipOrder', 'Activity_Approve', 'sf_approveToDone']); + expect(result.result.created.map((created) => created.id)).toEqual(['svc_bookOrder', 'sf_bookOrderToShipOrder', 'userTask_approve', 'sf_approveToDone']); const none = await applyToXml(PASCAL, [{ op: 'add', kind: 'serviceTask', name: 'Book order', after: 'Activity_CheckOrder' }], { layout: false, conventions: { prefixes: { 'bpmn:ServiceTask': '1_' } } }); - const plain = await applyToXml(PASCAL, [{ op: 'add', kind: 'serviceTask', name: 'Book order', after: 'Activity_CheckOrder' }], { layout: false }); - expect(none.xml).toBe(plain.xml); - expect(none.result.conventions).toBeUndefined(); + expect(none.result.created.map((created) => created.id)).toEqual(['serviceTask_bookOrder', 'sequenceFlow_bookOrderToShipOrder']); }); }); -describe('the conventions of a result', () => { - it('name the new ids that differ from the file\'s own style, in text, --summary and JSON; none in a file whose checked ids all follow the rule', async () => { - const result = await run(PASCAL, [{ op: 'add', kind: 'serviceTask', name: 'Book order', after: 'Activity_CheckOrder' }]); - const report = mutationReport(result); - expect(report.conventions).toEqual({ ids: ['serviceTask_bookOrder', 'flow_checkOrderToBookOrder', 'flow_bookOrderToShipOrder'], source: '.bpmnlintrc in .' }); - const line = "conventions: serviceTask_bookOrder, flow_checkOrderToBookOrder, flow_bookOrderToShipOrder follow element-id-naming (.bpmnlintrc in .), not the file's own id style (`bpmn show --conventions`)"; - expect(renderMutation(report).split('\n')).toContain(line); - expect(mutationSummary(report).conventions).toEqual(report.conventions); - expect(renderSummary(mutationSummary(report)).split('\n')).toContain(line); - const same = await run(CONFORMING, [ - { op: 'add', kind: 'serviceTask', name: 'Book order', after: 'serviceTask_checkOrder' }, - { op: 'add', kind: 'endEvent', name: 'Cancelled', in: 'Process_1' }, - ]); - expect(createdIds(same)).toEqual(['serviceTask_bookOrder', 'flow_bookOrderToShipOrder', 'endEvent_cancelled']); - expect(same.conventions).toBeUndefined(); - expect(renderMutation(mutationReport(same))).not.toContain('conventions:'); - // a kind the conforming file has no id of yet: its own style shows none (exclusiveGateway_ by the type-named - // prefixes), but none of its ids differs from the rule, so nothing of its own differs either - const gateway = await run(CONFORMING, [{ op: 'add', kind: 'xor', name: 'Approved?', after: 'serviceTask_checkOrder' }]); - expect(createdIds(gateway)).toEqual(['gateway_approved', 'flow_approvedToShipOrder']); - expect(gateway.conventions).toBeUndefined(); - // one id of its own that the rule rejects (a body not in its case counts too) makes it a file of two styles again - for (const legacy of ['', '']) { - const mixed = await run(CONFORMING.replace('', `${legacy}`), [{ op: 'add', kind: 'xor', name: 'Approved?', after: 'serviceTask_checkOrder' }]); - expect(mixed.conventions?.ids, legacy).toContain('gateway_approved'); - } - // so does one of a type the rule checks with a prefix new ids cannot have (W_CONFIG_IGNORED: no id passes it) - const ignoredPrefix = declared({ prefixes: { 'bpmn:ReceiveTask': '1bad' } }); - const receive = await run(CONFORMING.replace('', ''), [{ op: 'add', kind: 'xor', name: 'Approved?', after: 'serviceTask_checkOrder' }], ignoredPrefix); - expect(receive.conventions?.ids).toContain('gateway_approved'); - // and an event whose qualified prefix the rule requires and new ids cannot have (longer than 56 characters) - const long = `endEvent${'X'.repeat(40)}_`; - const required = declared({ eventDefinitionQualifier: 'required', prefixes: { 'bpmn:EndEvent': long } }); - const longEnd = CONFORMING.replaceAll('"endEvent_done"', `"${long}done"`); - const approve = [{ op: 'add', kind: 'xor', name: 'Approved?', after: 'serviceTask_checkOrder' }]; - expect((await run(longEnd, approve, required)).conventions).toBeUndefined(); - const escalated = longEnd.replace('', ''); - expect((await run(escalated, approve, required)).conventions?.ids).toContain('gateway_approved'); - }); - - it('a new file has no style of its own: no line for its first ids, nor for a process id the rule names exactly', async () => { +describe('a new file under the rule', () => { + it('the process and the definitions of an unnamed model take an exact prefix; one change after another follows the rule', async () => { const processOnly = declared({ prefixes: { 'bpmn:Process': 'process_' } }); const unnamed = await mutateDoc(Doc.create({ conventions: processOnly }), [], { layout: false, conventions: processOnly }); - expect(unnamed.xml).toContain('id="Definitions_Main"'); + expect(unnamed.xml).toContain('id="definitions_main"'); expect(unnamed.xml).toContain(' { [{ op: 'add', kind: 'xor', name: 'Approved?', after: 'serviceTask_bookOrder' }], [{ op: 'split', after: 'gateway_approved', kind: 'and', branches: [{ nodes: [{ kind: 'userTask', name: 'Pack' }] }, { nodes: [{ kind: 'userTask', name: 'Bill' }] }] }], ]) { - const result = await mutateDoc(doc, ops as EditCommand[], { layout: false, conventions: BARE }); - lines.push(result.conventions); - expect(renderSummary(mutationSummary(mutationReport(result)))).not.toContain('conventions:'); - doc = await Doc.fromXml(result.xml); + doc = await Doc.fromXml((await mutateDoc(doc, ops as EditCommand[], { layout: false, conventions: BARE })).xml); } - expect(lines).toEqual([undefined, undefined, undefined, undefined, undefined]); expect(doc.flowElements(doc.processes()[0]!).map((element) => element.get('id'))).toEqual(expect.arrayContaining(['gateway_approved', 'gateway_afterApproved', 'gateway_afterApprovedJoin', 'userTask_pack', 'userTask_bill'])); }); - it('only ids under a prefix the rule declares: not what it does not name (a new file\'s lane, a pool, a message flow), nor a type whose prefix it cannot use', async () => { + it('a lane, pools and a message flow take the rule\'s defaults; a type whose prefix it cannot use bpmn-cli\'s own', async () => { const fresh = await run(definitionsXml(''), [ { op: 'add', kind: 'lane', name: 'Sales', in: 'Process_1' }, { op: 'add', kind: 'participant', name: 'Customer' }, { op: 'add', kind: 'participant', name: 'Bank', as: '$b' }, { op: 'connect', source: 'sendTask_send', target: '$b' }, ]); - expect(createdIds(fresh)).toEqual(['lane_sales', 'collaboration_process', 'participant_customer', 'Process_Bank', 'participant_bank', 'flow_sendToBank']); - expect(fresh.conventions).toBeUndefined(); + expect(createdIds(fresh)).toEqual(['lane_sales', 'collaboration_process', 'participant_customer', 'bank', 'participant_bank', 'messageFlow_sendToBank']); const ignored = declared({ prefixes: { 'bpmn:UserTask': '1user_' } }); expect(ignored.ignored).toEqual(['bpmn:UserTask']); - const user = await run(definitionsXml(''), [{ op: 'add', kind: 'userTask', name: 'Check it', in: 'Process_1' }], ignored); - expect(createdIds(user)).toEqual(['userTask_checkIt']); - expect(user.conventions).toBeUndefined(); + expect(createdIds(await run(definitionsXml(''), [{ op: 'add', kind: 'userTask', name: 'Check it', in: 'Process_1' }], ignored))).toEqual(['userTask_checkIt']); }); }); @@ -657,14 +501,6 @@ describe('W_ID_PREFIX_STALE', () => { expect(retyped.changes.changed.map((changed) => changed.id)).toEqual(['userTask_checkInvoice']); expect(retyped.changes.renames).toEqual([{ from: 'serviceTask_checkInvoice', to: 'userTask_checkInvoice', regenerated: true }]); expect(mutationReport(retyped).renamed).toBeUndefined(); - // the file follows the rule: no `conventions:` line; in a file of its own style next to it (Activity_Legacy) the - // line names the new id where it named the old one - expect(retyped.conventions).toBeUndefined(); - const mixed = await run(start.replace('', ''), [ - { op: 'add', kind: 'serviceTask', name: 'Check invoice', after: 'startEvent_start', as: '$t' }, - { op: 'retype', id: '$t', kind: 'userTask' }, - ]); - expect(mixed.conventions?.ids).toEqual(['flow_startToCheckInvoice', 'userTask_checkInvoice']); expect(retyped.xml).toContain(' { it('the flows the batch named after such an element follow its new id: a prefix without separator, flows named by the ends\' ids, a suffix that no longer applies, a message flow', async () => { const glued = declared({ case: 'PascalCase', prefixes: { 'bpmn:ServiceTask': 'ST', 'bpmn:UserTask': 'UT', 'bpmn:SendTask': 'SN', 'bpmn:ReceiveTask': 'RT', 'bpmn:SequenceFlow': 'SF' } }); const start = definitionsXml(''); - // a task of the file's own style next to the rule: the result's `conventions:` line names the new ids const result = await run(start.replace('', ''), [ { op: 'add', kind: 'serviceTask', name: 'Check', after: 'startEvent_Go', as: '$t' }, { op: 'retype', id: '$t', kind: 'userTask' }, @@ -729,7 +564,6 @@ describe('W_ID_PREFIX_STALE', () => { { from: 'STCheck', to: 'UTCheck', regenerated: true }, { from: 'SFGoToSTCheck', to: 'SFGoToUTCheck', was: 'startEvent_Go -> STCheck', regenerated: true }, ]); - expect(result.conventions?.ids).toEqual(['UTCheck', 'SFGoToUTCheck']); // it names its ends, so a later write renames it after its new ones const later = await run(result.xml, [{ op: 'add', kind: 'task', name: 'Prepare', flow: 'SFGoToUTCheck' }], glued); expect(later.changes.renames).toEqual([expect.objectContaining({ from: 'SFGoToUTCheck', to: 'SFGoToPrepare' })]); @@ -739,7 +573,7 @@ describe('W_ID_PREFIX_STALE', () => { { op: 'retype', id: '$t', kind: 'userTask' }, { op: 'set', id: 'SFGoToSTCheck', values: { name: 'x' } }, ], glued)).rejects.toThrow('No element with id "SFGoToSTCheck": an op before this one renamed it to SFGoToUTCheck (its id named its old ends)'); - // a message flow, its prefix borrowed from the sequence flows + // a message flow, with its own default prefix const pools = ` `; const message = await run(definitionsXml('', { extraRoots: pools }), [ @@ -747,25 +581,18 @@ describe('W_ID_PREFIX_STALE', () => { { op: 'connect', source: '$t', target: 'RTGet' }, { op: 'retype', id: '$t', kind: 'sendTask' }, ], glued); - expect(createdIds(message)).toEqual(['SNAsk', 'SFGoToSNAsk', 'SFSNAskToRTGet']); - // flows named by their ends' ids (idPair), the spliced one too + expect(createdIds(message)).toEqual(['SNAsk', 'SFGoToSNAsk', 'messageFlow_SNAskToRTGet']); + // flows named by their ends' ids (idPair) are no form of bpmn-cli's: the spliced one keeps its id, the new one is named const idPair = definitionsXml(` flow_task_b_endEvent_done flow_task_b_endEvent_done - - flow_task_x_task_y - flow_task_x_task_y - `); + `); const pair = await run(idPair, [ { op: 'add', kind: 'serviceTask', name: 'Check', after: 'task_b' }, { op: 'retype', id: 'serviceTask_check', kind: 'userTask' }, ], declared({ case: 'any' })); - expect(createdIds(pair)).toEqual(['userTask_check', 'flow_userTask_check_endEvent_done']); - expect(pair.changes.changed.map((changed) => [changed.id, changed.detail])).toEqual([ - ['flow_task_b_userTask_check', 'task_b -> userTask_check (was -> endEvent_done) (renamed from flow_task_b_endEvent_done: its id named its old ends)'], - ['userTask_check', 'retyped from serviceTask to userTask'], - ]); - expect(mutationReport(pair).renamed).toEqual({ flow_task_b_endEvent_done: 'flow_task_b_userTask_check' }); + expect(createdIds(pair)).toEqual(['userTask_check', 'flow_checkToDone']); + expect(mutationReport(pair).renamed).toBeUndefined(); // the suffix of the old id (serviceTask_check2) is not the new one's: the flow loses it too const twice = await run(definitionsXml(''), [ { op: 'add', kind: 'serviceTask', name: 'Check', in: 'Process_1', as: '$a' }, @@ -856,46 +683,56 @@ describe('bpmn show --conventions (the view)', () => { const view = conventionsView(await Doc.fromXml(PASCAL), BARE, { file: 'x.bpmn', severity: 'warn' }); expect(view).toMatchObject({ file: 'x.bpmn', exists: true, source: '.bpmnlintrc in .', rule: { severity: 'warn', case: 'camelCase', qualifier: 'optional' }, qualified: { mode: 'optional', examples: ['timerStartEvent_', 'messageEvent_', 'errorEndEvent_'] } }); const row = (kind: string) => view.rows.find((conventionRow) => conventionRow.kinds.includes(kind)); - expect(row('serviceTask')).toEqual({ pattern: '_{name:camel}', source: 'lint', kinds: ['task', 'userTask', 'serviceTask', 'scriptTask', 'sendTask', 'receiveTask', 'manualTask', 'businessRuleTask', 'subProcess', 'callActivity', 'startEvent', 'endEvent'] }); - // what the rule does not name follows the file: its type-named PascalCase prefixes; the process bpmn-cli's own - expect(row('lane')).toEqual({ pattern: '_{name:pascal}', source: 'file', kinds: ['participant', 'lane', 'textAnnotation', 'group', 'collaboration', 'message'], note: 'not checked' }); - expect(row('process')).toEqual({ pattern: 'Process_{name:pascal}', source: 'default', kinds: ['process'], note: 'not checked' }); - expect(row('dataObject')).toMatchObject({ pattern: 'DataObjectReference_{name:pascal}', source: 'file' }); + expect(row('serviceTask')).toEqual({ pattern: '_{name:camel}', source: 'lint', kinds: ['task', 'userTask', 'serviceTask', 'scriptTask', 'sendTask', 'receiveTask', 'manualTask', 'businessRuleTask', 'subProcess', 'callActivity', 'startEvent', 'endEvent', 'participant', 'lane', 'dataObject', 'dataStore', 'textAnnotation', 'group', 'collaboration'] }); + // what the rule does not check takes bpmn-cli's own prefix, whatever the file's ids; a named process its name alone + expect(row('process')).toEqual({ pattern: '{name:camel}', source: 'bpmn-cli', kinds: ['process'], note: 'not checked; unnamed: Process_1' }); + expect(row('message')).toEqual({ pattern: 'message_{name:camel}', source: 'bpmn-cli', kinds: ['message'], note: 'not checked' }); expect(row('sequenceFlow')).toEqual({ pattern: 'flow_{source}To{target}', source: 'lint', kinds: ['sequenceFlow'] }); - expect(row('messageFlow')).toEqual({ pattern: 'flow_{source}To{target}', source: 'lint shape', kinds: ['messageFlow'], note: 'not checked' }); + expect(row('messageFlow')).toEqual({ pattern: 'messageFlow_{source}To{target}', source: 'lint', kinds: ['messageFlow'] }); const text = renderConventions(view); expect(text.split('\n').slice(0, 2)).toEqual(['conventions of x.bpmn', 'element-id-naming (.bpmnlintrc in .): warn, case camelCase, eventDefinitionQualifier optional']); expect(text).toContain(' events with a trigger: (timerStartEvent_, messageEvent_, errorEndEvent_) also accepted, new ids take the plain prefix'); + expect(text.split('\n')).toContain("sources: lint = element-id-naming; bpmn-cli = bpmn-cli's own prefix for a type element-id-naming does not check"); // required: the qualified prefix is the one new ids take expect(renderConventions(conventionsView(undefined, REQUIRED))).toContain('required, new ids take it'); }); - it('a new file, and no conventions declared', async () => { + it('the flow rows join the ends in the declared case: To, _to_ under snake_case, -to- under kebab-case', () => { + for (const [config, join] of [[{}, 'To'], [{ case: 'snake_case' }, '_to_'], [{ case: 'kebab-case' }, '-to-']] as const) { + const rows = conventionsView(undefined, declared(config)).rows; + expect(rows.filter((row) => row.kinds.includes('sequenceFlow') || row.kinds.includes('messageFlow')).map((row) => row.pattern), join).toEqual([`flow_{source}${join}{target}`, `messageFlow_{source}${join}{target}`]); + } + }); + + it('a new file, and no conventions declared: the defaults of element-id-naming', async () => { const fresh = await conventionsXml(undefined, { conventions: BARE, file: 'new.bpmn' }); expect(fresh.exists).toBe(false); - expect(fresh.rows.find((conventionRow) => conventionRow.kinds.includes('lane'))).toMatchObject({ pattern: '_{name:camel}', source: 'lint shape' }); - expect(fresh.rows.find((conventionRow) => conventionRow.kinds.includes('process'))).toMatchObject({ pattern: 'Process_{name:pascal}', source: 'default' }); + expect(fresh.rows.find((conventionRow) => conventionRow.kinds.includes('lane'))).toMatchObject({ pattern: '_{name:camel}', source: 'lint' }); + expect(fresh.rows.find((conventionRow) => conventionRow.kinds.includes('process'))).toMatchObject({ pattern: '{name:camel}', source: 'bpmn-cli' }); expect(renderConventions(fresh).split('\n')[0]).toBe('conventions of new.bpmn (no file yet: a new model)'); const none = await conventionsXml(PASCAL); expect(none.rule).toBeUndefined(); - expect(none.rows.find((conventionRow) => conventionRow.kinds.includes('serviceTask'))).toEqual({ pattern: 'Activity_{name:pascal}', source: 'file', kinds: ['task', 'userTask', 'serviceTask', 'scriptTask', 'sendTask', 'receiveTask', 'manualTask', 'businessRuleTask', 'subProcess', 'eventSubProcess', 'adHocSubProcess', 'transaction', 'callActivity'] }); - expect(renderConventions(none).split('\n')[1]).toBe("no id conventions declared: new ids follow the file's id style"); + expect(none.qualified).toEqual({ mode: 'optional', examples: ['timerStartEvent_', 'messageEvent_', 'errorEndEvent_'] }); + expect(none.rows.find((conventionRow) => conventionRow.kinds.includes('serviceTask'))).toMatchObject({ pattern: '_{name:camel}', source: 'default' }); + expect(none.rows.find((conventionRow) => conventionRow.kinds.includes('process'))).toMatchObject({ pattern: '{name:camel}', source: 'bpmn-cli' }); + const lines = renderConventions(none).split('\n'); + expect(lines[1]).toBe("no id conventions declared: new ids follow element-id-naming's defaults (case camelCase)"); + expect(lines).toContain(' events with a trigger: (timerStartEvent_, messageEvent_, errorEndEvent_) also accepted, new ids take the plain prefix'); + expect(lines).toContain("sources: default = element-id-naming's defaults (nothing declared); bpmn-cli = bpmn-cli's own prefix for a type element-id-naming does not check"); }); it('says why none are declared; W_LINT_UNAVAILABLE among the notes', async () => { const doc = await Doc.fromXml(PASCAL); const line = (opts: Parameters[2], conventions?: IdConventions) => renderConventions(conventionsView(doc, conventions, opts)).split('\n')[1]; - expect(line({ undeclared: 'skipped' })).toBe("no id conventions: --no-lint-config leaves the .bpmnlintrc out; new ids follow the file's id style"); - expect(line({ undeclared: 'no config' })).toBe("no id conventions: no .bpmnlintrc applies here; new ids follow the file's id style"); - expect(line({ undeclared: 'off', where: '.bpmnlintrc in models' })).toBe("no id conventions: element-id-naming is off or not configured in the .bpmnlintrc in models; new ids follow the file's id style"); - // a host table without a usable prefix - expect(line({}, { prefixes: { 'bpmn:ServiceTask': '1_' }, source: 'our rules' })).toBe("no id conventions: element-id-naming (our rules) declares no prefix new ids can follow; new ids follow the file's id style"); + expect(line({ undeclared: 'skipped' })).toBe("no id conventions: --no-lint-config leaves the .bpmnlintrc out; new ids follow element-id-naming's defaults (case camelCase)"); + expect(line({ undeclared: 'no config' })).toBe("no id conventions: no .bpmnlintrc applies here; new ids follow element-id-naming's defaults (case camelCase)"); + expect(line({ undeclared: 'off', where: '.bpmnlintrc in models' })).toBe("no id conventions: element-id-naming is off or not configured in the .bpmnlintrc in models; new ids follow element-id-naming's defaults (case camelCase)"); const notes = [{ severity: 'warning' as const, code: 'W_LINT_UNAVAILABLE', message: '.bpmnlintrc is not JSON', hint: 'Write it as strict JSON.' }]; const broken = conventionsView(doc, undefined, { undeclared: 'not loaded', where: '.bpmnlintrc in .', notes }); expect(broken).toMatchObject({ source: '.bpmnlintrc in .', undeclared: 'not loaded', notes }); expect(renderConventions(broken).split('\n')).toEqual(expect.arrayContaining([ - "no id conventions: the .bpmnlintrc in . could not be loaded (W_LINT_UNAVAILABLE below); new ids follow the file's id style", + "no id conventions: the .bpmnlintrc in . could not be loaded (W_LINT_UNAVAILABLE below); new ids follow element-id-naming's defaults (case camelCase)", 'warning W_LINT_UNAVAILABLE: .bpmnlintrc is not JSON (Write it as strict JSON.)', ])); // declared: no reason @@ -905,45 +742,16 @@ describe('bpmn show --conventions (the view)', () => { it('a type the rule checks with a prefix new ids cannot follow is marked so, not `not checked`', async () => { const view = conventionsView(await Doc.fromXml(PASCAL), declared({ prefixes: { 'bpmn:UserTask': 'user task_', 'bpmn:Gateway': 'a gate_' } })); const row = (kind: string) => view.rows.find((conventionRow) => conventionRow.kinds.includes(kind)); - expect(row('userTask')).toMatchObject({ source: 'file', note: 'checked; prefix ignored (W_CONFIG_IGNORED)' }); - expect(row('exclusiveGateway')).toMatchObject({ source: 'file', kinds: ['exclusiveGateway', 'parallelGateway', 'inclusiveGateway', 'complexGateway', 'eventBasedGateway'], note: 'checked; prefix ignored (W_CONFIG_IGNORED)' }); - expect(row('lane')?.note).toBe('not checked'); - // under required, the triggers whose qualified prefix is too long for new ids: they follow the file's style + expect(row('userTask')).toEqual({ pattern: '_{name:camel}', source: 'bpmn-cli', kinds: ['userTask', 'exclusiveGateway', 'parallelGateway', 'inclusiveGateway', 'complexGateway', 'eventBasedGateway'], note: 'checked; prefix ignored (W_CONFIG_IGNORED)' }); + expect(row('message')?.note).toBe('not checked'); + // under required, the triggers whose qualified prefix is too long for new ids: they take the plain prefix const long = conventionsView(undefined, declared({ case: 'snake_case', eventDefinitionQualifier: 'required', prefixes: { 'bpmn:EndEvent': 'endEventWithAVeryLongPrefixThatIsFiftyCharactersX_' } })); expect(long.rows.find((conventionRow) => conventionRow.kinds.includes('endEvent'))).toEqual({ pattern: 'endEventWithAVeryLongPrefixThatIsFiftyCharactersX_{name:snake}', source: 'lint', kinds: ['endEvent'], - note: "message, escalation, terminate, compensate: qualified prefix ignored, the file's style (W_CONFIG_IGNORED)", + note: 'message, escalation, terminate, compensate: qualified prefix ignored, new ids take the plain one (W_CONFIG_IGNORED)', }); expect(renderConventions(long)).toContain(') required, new ids take it (not the triggers marked above)'); }); - - it('the flow pattern is the head new flows get: F_ after numbered F1..F12, flow glued after flow1..flow12, a long prefix where the ends fit, Flow_ after one that leaves no room', async () => { - const numbered = (ids: string[]) => definitionsXml(` - - - - - - - `); - for (const [ids, pattern, id] of [ - [['F1', 'F2', 'F3'], 'F_{source}To{target}', 'F_PackGoodsToEnd'], - [['flow1', 'flow2', 'flow3'], 'flow{source}To{target}', 'flowPackGoodsToEnd'], - // a prefix that leaves the ends fewer than 16 characters stays where they fit (before: Flow_PackGoodsToEnd) - [['Q'.repeat(47) + '_1', 'Q'.repeat(47) + '_2', 'Q'.repeat(47) + '_3'], `${'Q'.repeat(47)}_{source}To{target}`, `${'Q'.repeat(47)}_PackGoodsToEnd`], - [['Q'.repeat(60) + '_1', 'Q'.repeat(60) + '_2', 'Q'.repeat(60) + '_3'], 'Flow_{source}To{target}', 'Flow_PackGoodsToEnd'], - ] as const) { - const xml = numbered([...ids]); - const row = conventionsView(await Doc.fromXml(xml), undefined).rows.find((conventionRow) => conventionRow.kinds.includes('sequenceFlow')); - expect(row?.pattern, ids[0]).toBe(pattern); - expect(row?.note, ids[0]).toBe(ids[0].length === 49 ? 'Flow_ where the ends do not fit' : undefined); - expect(createdIds(await run(xml, [{ op: 'connect', source: 'Activity_PackGoods', target: 'EndEvent_End' }], null))).toEqual([id]); - } - // beside the long prefix the ends give up whole words; where their first words do not fit: Flow_ - const q47 = numbered(['Q'.repeat(47) + '_1', 'Q'.repeat(47) + '_2', 'Q'.repeat(47) + '_3']); - expect(createdIds(await run(q47, [{ op: 'connect', source: 'Activity_ShipGoods', target: 'Activity_PackGoods' }], null))).toEqual([`${'Q'.repeat(47)}_ShipToPackGoods`]); - expect(createdIds(await run(q47, [{ op: 'add', kind: 'task', name: 'Warenwirtschaftssystemabgleich', after: 'Activity_ShipGoods' }], null))).toEqual(['Activity_Warenwirtschaftssystemabgleich', 'Flow_WarenwirtschaftssystemabgleichToEnd']); - }); }); diff --git a/test/domain/validation/conventions-lint.test.ts b/test/domain/validation/conventions-lint.test.ts index ca4c7fcc..689b8841 100644 --- a/test/domain/validation/conventions-lint.test.ts +++ b/test/domain/validation/conventions-lint.test.ts @@ -154,16 +154,17 @@ describe('the ids of a new file pass element-id-naming (the real rule)', () => { } }); - it('without declared conventions the same batch gives ids the rule reports (the control)', async () => { + it('without declared conventions the same batch passes the rule with its defaults', async () => { const doc = Doc.create({ processName: 'Invoice handling' }, 'invoice-handling.bpmn'); - const result = await mutateDoc(doc, everyKind('Process_InvoiceHandling'), { layout: false, force: true }); - expect((await lint(result.xml, { severity: 'warn', config: {} })).length).toBeGreaterThan(50); + const result = await mutateDoc(doc, everyKind(doc.processes()[0]!.get('id')), { layout: false, force: true }); + expect(result.changes.created.length).toBeGreaterThan(100); + expect(await lint(result.xml, { severity: 'warn', config: {} })).toEqual([]); }); - it('the process and the definitions keep their ids unless a prefix names their type exactly', async () => { + it('a named process gets no prefix and the definitions take definitions_, unless a prefix names their type exactly', async () => { const bare = conventionsOf({ severity: 'warn', config: {} }); const plain = Doc.create({ processName: 'Invoice handling', conventions: bare }); - expect([plain.processes()[0]!.get('id'), plain.definitions.get('id')]).toEqual(['Process_InvoiceHandling', 'Definitions_InvoiceHandling']); + expect([plain.processes()[0]!.get('id'), plain.definitions.get('id')]).toEqual(['invoiceHandling', 'definitions_invoiceHandling']); const exact = conventionsOf({ severity: 'warn', config: { prefixes: { 'bpmn:Process': 'process_', 'bpmn:Definitions': 'definitions_' } } }); const named = Doc.create({ processName: 'Invoice handling', conventions: exact }); expect([named.processes()[0]!.get('id'), named.definitions.get('id')]).toEqual(['process_invoiceHandling', 'definitions_invoiceHandling']); @@ -331,8 +332,8 @@ describe('idConventionsFromLint', () => { const bare = conventionsOf({ severity: 'warn', config: {} })!; const plain = Object.keys(bare.prefixes).filter((key) => key.split(':').length === 2); const qualified = Object.keys(bare.prefixes).filter((key) => key.split(':').length === 3); - // every task and sub-process, every gateway (complex too), every event, sequence flows - expect(plain.length).toBe(23); + // every task and sub-process, every gateway (complex too), every event, flows, pools, lanes, data, artifacts + expect(plain.length).toBe(34); expect(bare.prefixes).toMatchObject({ 'bpmn:ServiceTask': 'serviceTask_', 'bpmn:AdHocSubProcess': 'subProcess_', @@ -340,12 +341,18 @@ describe('idConventionsFromLint', () => { 'bpmn:ComplexGateway': 'gateway_', 'bpmn:BoundaryEvent': 'event_', 'bpmn:SequenceFlow': 'flow_', + 'bpmn:MessageFlow': 'messageFlow_', + 'bpmn:Participant': 'participant_', + 'bpmn:Lane': 'lane_', + 'bpmn:DataObjectReference': 'dataObject_', + 'bpmn:DataStoreReference': 'dataStore_', + 'bpmn:Association': 'association_', 'bpmn:StartEvent:timer': 'timerStartEvent_', 'bpmn:BoundaryEvent:message': 'messageEvent_', 'bpmn:EndEvent:cancel': 'cancelEndEvent_', }); expect(qualified.length).toBe(32); - for (const type of ['bpmn:Process', 'bpmn:Definitions', 'bpmn:Lane', 'bpmn:Participant', 'bpmn:MessageFlow', 'bpmn:Message']) expect(bare.prefixes[type], type).toBeUndefined(); + for (const type of ['bpmn:Process', 'bpmn:Definitions', 'bpmn:Message', 'bpmn:DataObject', 'bpmn:DataStore']) expect(bare.prefixes[type], type).toBeUndefined(); expect(bare).toMatchObject({ case: 'camelCase', qualifier: 'optional', source: '.bpmnlintrc in .' }); // the qualifier off: no qualified prefixes; case any: none declared const off = conventionsOf({ severity: 'error', config: { eventDefinitionQualifier: 'off', case: 'any' } })!; @@ -358,11 +365,11 @@ describe('idConventionsFromLint', () => { expect(exact.prefixes['bpmn:Definitions']).toBe('definitions_'); }); - it('declares nothing for the severity off, no setting, or every prefix off', () => { + it('declares nothing for the severity off or no setting; every prefix off declares the case and no prefix', () => { expect(idConventionsFromLint(undefined, naming)).toEqual({ notes: [] }); expect(idConventionsFromLint({ severity: 'off', config: { case: 'nonsense' } }, naming)).toEqual({ notes: [] }); const all = Object.fromEntries(Object.keys(naming.DEFAULT_PREFIXES).map((key) => [key, false])); - expect(idConventionsFromLint({ severity: 'warn', config: { prefixes: all } }, naming).conventions).toBeUndefined(); + expect(idConventionsFromLint({ severity: 'warn', config: { prefixes: all } }, naming).conventions).toEqual({ prefixes: {}, qualifier: 'optional', case: 'camelCase' }); }); it('a prefix new ids cannot start with is left out with one W_CONFIG_IGNORED naming the .bpmnlintrc, the key and the value', () => { @@ -371,13 +378,13 @@ describe('idConventionsFromLint', () => { expect(conventions!.prefixes['bpmn:ScriptTask']).toBe('scriptTask_'); // the concrete types the rule still checks with them (show --conventions marks them) expect(conventions!.ignored).toEqual(expect.arrayContaining(['bpmn:UserTask', 'bpmn:ExclusiveGateway', 'bpmn:ComplexGateway', 'bpmn:ServiceTask', 'bpmn:SendTask', 'bpmn:Task'])); - // one note per configured type (every gateway falls back to bpmn:Gateway) - expect(notes.map((note) => note.code)).toEqual(Array(5).fill('W_CONFIG_IGNORED')); expect(notes[0]).toMatchObject({ severity: 'warning', - message: ".bpmnlintrc in models: @miragon/rules/element-id-naming prefix of bpmn:UserTask \"user task_\" cannot start an id (a letter or _ first, then letters, digits, _ . -, at most 56 characters): new ids of the type follow the learned id style, and the rule reports them", + message: ".bpmnlintrc in models: @miragon/rules/element-id-naming prefix of bpmn:UserTask \"user task_\" cannot start an id (a letter or _ first, then letters, digits, _ . -, at most 56 characters): new ids of the type take bpmn-cli's own prefix, and the rule reports them", hint: 'Fix the prefix in the .bpmnlintrc, or set it to false to switch bpmn:UserTask off.', }); + // one note per configured type (every gateway falls back to bpmn:Gateway) + expect(notes.map((note) => note.code)).toEqual(Array(5).fill('W_CONFIG_IGNORED')); expect(notes.map((note) => note.message.split(' "')[0])).toContain('.bpmnlintrc in models: @miragon/rules/element-id-naming prefix of bpmn:Gateway'); }); @@ -407,7 +414,7 @@ describe('idConventionsFromLint', () => { const { conventions, notes } = idConventionsFromLint({ severity: 'warn', config: { case: 'camelcase', eventDefinitionQualifier: 'requried' } }, naming); expect(conventions).toMatchObject({ case: 'PascalCase', qualifier: 'optional' }); expect(notes.map((note) => [note.message, note.hint])).toEqual([ - ['.bpmnlintrc: @miragon/rules/element-id-naming case "camelcase" is unknown: the rule checks PascalCase, and new ids take it', 'Write one of camelCase, PascalCase, snake_case, any in the .bpmnlintrc. Did you mean camelCase?'], + ['.bpmnlintrc: @miragon/rules/element-id-naming case "camelcase" is unknown: the rule checks PascalCase, and new ids take it', 'Write one of camelCase, PascalCase, snake_case, kebab-case, any in the .bpmnlintrc. Did you mean camelCase?'], ['.bpmnlintrc: @miragon/rules/element-id-naming eventDefinitionQualifier "requried" is unknown: the rule and new ids take optional', 'Write one of off, optional, required in the .bpmnlintrc. Did you mean required?'], ]); // the hint's guess: any case, - or _, a prefix, a small typo; nothing for a value that means something else @@ -418,42 +425,32 @@ describe('idConventionsFromLint', () => { expect(hint({ eventDefinitionQualifier: 'on' })).toBe('Write one of off, optional, required in the .bpmnlintrc.'); expect(hint({ eventDefinitionQualifier: '' })).toBe('Write one of off, optional, required in the .bpmnlintrc.'); // the rule's own spellings give no note; ["warn"] and the bare flag are its defaults - for (const config of [undefined, {}, { case: 'snake_case', eventDefinitionQualifier: 'off' }]) expect(idConventionsFromLint({ severity: 'warn', config }, naming).notes).toEqual([]); + for (const config of [undefined, {}, { case: 'snake_case', eventDefinitionQualifier: 'off' }, { case: 'kebab-case' }]) expect(idConventionsFromLint({ severity: 'warn', config }, naming).notes).toEqual([]); }); - it('says why a value is ignored: a case named like a property of JavaScript\'s Object (unknown like any other; a plugin before 0.12.1 cannot check it), a prefix outside ASCII', async () => { + it('says why a value is ignored: a case named like a property of JavaScript\'s Object (unknown like any other), a prefix outside ASCII', async () => { const { conventions, notes } = idConventionsFromLint({ severity: 'warn', config: { case: 'toString', prefixes: { 'bpmn:UserTask': 'prüfung_' } } }, naming); expect(conventions).toMatchObject({ case: 'PascalCase', ignored: ['bpmn:UserTask'] }); expect(notes.map((note) => note.message)).toEqual([ '.bpmnlintrc: @miragon/rules/element-id-naming case "toString" is unknown: the rule checks PascalCase, and new ids take it', - '.bpmnlintrc: @miragon/rules/element-id-naming prefix of bpmn:UserTask "prüfung_" has a character outside ASCII, which bpmn-cli does not write in ids: new ids of the type follow the learned id style, and the rule reports them', + '.bpmnlintrc: @miragon/rules/element-id-naming prefix of bpmn:UserTask "prüfung_" has a character outside ASCII, which bpmn-cli does not write in ids: new ids of the type take bpmn-cli\'s own prefix, and the rule reports them', ]); expect(conventionsOf({ severity: 'warn', config: { case: 'constructor' } })?.case).toBe('PascalCase'); - // the rule checks PascalCase there (since @miragon/bpmnlint-plugin-rules 0.12.1; 0.12.0 could not: it reported - // for an id without the prefix and threw on the first id with it), for __proto__ too + // the rule checks PascalCase there, for __proto__ too const ids = wrap(''); for (const name of ['toString', '__proto__']) { expect((await lint(ids, { severity: 'warn', config: { case: name } })).map((finding) => [finding.id, finding.message])).toEqual([['userTask_checkInvoice', 'Element id must match the naming convention ']]); expect(idConventionsFromLint({ severity: 'warn', config: { case: name } }, naming).notes.map((note) => note.message)).toEqual([`.bpmnlintrc: @miragon/rules/element-id-naming case "${name}" is unknown: the rule checks PascalCase, and new ids take it`]); } - // injected helpers of a plugin before 0.12.1 answer such a name with Object.prototype's property (a function, or - // Object.prototype itself), which has no label: new ids take the fallback, and the note says that rule cannot check it - const older = { ...naming, resolveCase: (caseName: string) => (({}) as Record)[caseName] as ReturnType ?? naming.resolveCase(caseName) }; - for (const name of ['toString', '__proto__']) { - const old = idConventionsFromLint({ severity: 'warn', config: { case: name } }, older); - expect(old.conventions?.case).toBe('PascalCase'); - expect(old.notes.map((note) => note.message)).toEqual([`.bpmnlintrc: @miragon/rules/element-id-naming case "${name}" is unknown, and the rule of a @miragon/bpmnlint-plugin-rules before 0.12.1 cannot check it: new ids take PascalCase, the fallback for an unknown case`]); - } - expect(idConventionsFromLint({ severity: 'warn', config: { case: 'snake_case' } }, older).notes).toEqual([]); }); - it('under required, a qualified prefix too long for new ids is one note; such events follow the learned style, no W_ID_PREFIX_STALE names the plain prefix', async () => { + it('under required, a qualified prefix too long for new ids is one note; such events take the plain prefix, no W_ID_PREFIX_STALE names it', async () => { const plain = 'endEventOfTheOrderHandlingProcessInTheBackOffice_'; const setting: BpmnlintRuleSetting = { severity: 'warn', config: { eventDefinitionQualifier: 'required', prefixes: { 'bpmn:EndEvent': plain } } }; const { conventions, notes } = idConventionsFromLint(setting, naming); // escalation, terminate and compensate make it longer than 56 characters; error, signal and message do not expect(notes.map((note) => note.message.split(' "')[0])).toEqual(['escalation', 'terminate', 'compensate'].map((eventType) => `.bpmnlintrc: @miragon/rules/element-id-naming prefix of bpmn:EndEvent:${eventType}`)); - expect(notes[0]?.message).toContain('is longer than 56 characters (the bpmn:EndEvent prefix qualified with the trigger): new ids of such events follow the learned id style'); + expect(notes[0]?.message).toContain('is longer than 56 characters (the bpmn:EndEvent prefix qualified with the trigger): new ids of such events take the plain prefix, and the rule reports them'); expect(conventions!.ignored).toEqual(['bpmn:EndEvent:escalation', 'bpmn:EndEvent:terminate', 'bpmn:EndEvent:compensate']); expect(conventions!.prefixes['bpmn:EndEvent:error']).toBe(`error${plain.charAt(0).toUpperCase()}${plain.slice(1)}`); const xml = wrap(``); @@ -462,8 +459,7 @@ describe('idConventionsFromLint', () => { { op: 'retype', id: 'errorEndEventOfTheOrderHandlingProcessInTheBackOffice_fail', kind: 'endEvent:escalation', escalation: 'Late' }, ] as EditCommand[], { layout: false, conventions, force: true }); const late = result.changes.created.find((created) => created.name === 'Late' && created.kind.startsWith('endEvent'))!.id; - expect(late.startsWith(plain)).toBe(false); - expect(result.conventions?.ids ?? []).not.toContain(late); + expect(late.startsWith(plain)).toBe(true); expect(result.changes.warnings.filter((warning) => warning.code === 'W_ID_PREFIX_STALE')).toEqual([]); }); }); diff --git a/test/domain/validation/validators.test.ts b/test/domain/validation/validators.test.ts index a841ff5f..f698ce7a 100644 --- a/test/domain/validation/validators.test.ts +++ b/test/domain/validation/validators.test.ts @@ -126,22 +126,22 @@ describe('host validators on mutations (MutationOptions.validators)', () => { const file = await drawn('line.bpmn', LINE); const result = await refusal(file, LOOSE, { validators: [gate()] }); expect(result.code).toBe('E_VALIDATION'); - expect(codes(result.errors)).toEqual(['[gate] E_GATE_UNREACHABLE Activity_Loose', '[gate] E_GATE_DEAD_END Activity_Loose']); + expect(codes(result.errors)).toEqual(['[gate] E_GATE_UNREACHABLE task_loose', '[gate] E_GATE_DEAD_END task_loose']); expect(result.errors[0]).toMatchObject({ validator: 'gate', severity: 'error' }); const plain = await mutateFile(file, LOOSE, { dryRun: true }); expect(plain.validation.validators).toBeUndefined(); - expect(codes(plain.validation.warnings)).toEqual(expect.arrayContaining(['W_UNREACHABLE Activity_Loose', 'W_DEAD_END Activity_Loose'])); + expect(codes(plain.validation.warnings)).toEqual(expect.arrayContaining(['W_UNREACHABLE task_loose', 'W_DEAD_END task_loose'])); }); it('--force writes it and reports the errors with the validator', async () => { const file = await drawn('forced.bpmn', LINE); const result = await mutateFile(file, LOOSE, { validators: [gate()], force: true }); expect(result.written).toBe(true); - expect(codes(result.validation.errors)).toEqual(['[gate] E_GATE_UNREACHABLE Activity_Loose', '[gate] E_GATE_DEAD_END Activity_Loose']); + expect(codes(result.validation.errors)).toEqual(['[gate] E_GATE_UNREACHABLE task_loose', '[gate] E_GATE_DEAD_END task_loose']); expect(result.validation.validators).toEqual([expect.objectContaining({ name: 'gate', detail: 'the test host', counts: { errors: 2, warnings: 0 }, preexisting: [], resolved: [] })]); // the lint warnings about the same node are not repeated next to the errors that cover them - expect(codes(result.validation.warnings)).not.toContain('W_DEAD_END Activity_Loose'); - expect(codes(result.validation.warnings)).not.toContain('W_UNREACHABLE Activity_Loose'); + expect(codes(result.validation.warnings)).not.toContain('W_DEAD_END task_loose'); + expect(codes(result.validation.warnings)).not.toContain('W_UNREACHABLE task_loose'); }); it('an error the file already had does not block an edit; it is reported as W_PREEXISTING_ERROR and follows a rename', async () => { @@ -168,11 +168,11 @@ describe('host validators on mutations (MutationOptions.validators)', () => { it('a second start event is refused even with its path; a node and its flows in one transaction pass', async () => { const file = await drawn('starts.bpmn', LINE); - const second = await refusal(file, [{ op: 'add', kind: 'startEvent', name: 'Retry', in: 'P' }, { op: 'connect', source: 'Event_Retry', target: 'A' }], { validators: [gate()] }); - expect(codes(second.errors)).toEqual(['[gate] E_GATE_START_EVENTS S', '[gate] E_GATE_START_EVENTS Event_Retry']); + const second = await refusal(file, [{ op: 'add', kind: 'startEvent', name: 'Retry', in: 'P' }, { op: 'connect', source: 'startEvent_retry', target: 'A' }], { validators: [gate()] }); + expect(codes(second.errors)).toEqual(['[gate] E_GATE_START_EVENTS S', '[gate] E_GATE_START_EVENTS startEvent_retry']); const alone = await refusal(file, [{ op: 'add', kind: 'task', name: 'Parallel', in: 'P' }], { validators: [gate()] }); expect(alone.errors).toHaveLength(2); - const batch = await mutateFile(file, [{ op: 'add', kind: 'task', name: 'Parallel', in: 'P' }, { op: 'connect', source: 'A', target: 'Activity_Parallel' }, { op: 'connect', source: 'Activity_Parallel', target: 'E' }], { validators: [gate()], dryRun: true }); + const batch = await mutateFile(file, [{ op: 'add', kind: 'task', name: 'Parallel', in: 'P' }, { op: 'connect', source: 'A', target: 'task_parallel' }, { op: 'connect', source: 'task_parallel', target: 'E' }], { validators: [gate()], dryRun: true }); expect(batch.validation.validators?.[0]?.errors).toEqual([]); }); @@ -180,18 +180,18 @@ describe('host validators on mutations (MutationOptions.validators)', () => { const two = xml(` `); const file = await drawn('two-starts.bpmn', two); - const third = await refusal(file, [{ op: 'add', kind: 'startEvent', name: 'Third', in: 'P' }, { op: 'connect', source: 'Event_Third', target: 'A' }], { validators: [gate()] }); - expect(codes(third.errors)).toEqual(['[gate] E_GATE_START_EVENTS Event_Third']); + const third = await refusal(file, [{ op: 'add', kind: 'startEvent', name: 'Third', in: 'P' }, { op: 'connect', source: 'startEvent_third', target: 'A' }], { validators: [gate()] }); + expect(codes(third.errors)).toEqual(['[gate] E_GATE_START_EVENTS startEvent_third']); const removed = await mutateFile(file, [{ op: 'remove', ids: ['S2'] }], { validators: [gate()], dryRun: true }); expect(removed.validation.validators?.[0]).toMatchObject({ errors: [], resolved: [expect.objectContaining({ element: 'S' }), expect.objectContaining({ element: 'S2' })] }); }); it('warnings a change introduces are reported once; later writes only count them (a key keeps a finding whose message changes)', async () => { const file = await drawn('complex.bpmn', LINE); - const ops: EditCommand[] = Array.from({ length: 9 }, (_unused, index) => ({ op: 'add', kind: 'task', name: `Step ${index}`, after: index === 0 ? 'A' : `Activity_Step${index - 1}` })); + const ops: EditCommand[] = Array.from({ length: 9 }, (_unused, index) => ({ op: 'add', kind: 'task', name: `Step ${index}`, after: index === 0 ? 'A' : `task_step${index - 1}` })); const grown = await mutateFile(file, ops, { validators: [gate()] }); expect(codes(grown.validation.warnings).filter((code) => code.includes('GATE'))).toEqual(['[gate] W_GATE_SIZE']); - const again = await mutateFile(file, [{ op: 'add', kind: 'task', name: 'One more', after: 'Activity_Step8' }], { validators: [gate()] }); + const again = await mutateFile(file, [{ op: 'add', kind: 'task', name: 'One more', after: 'task_step8' }], { validators: [gate()] }); expect(codes(again.validation.warnings).filter((code) => code.includes('GATE'))).toEqual([]); expect(again.validation.validators?.[0]?.counts).toEqual({ errors: 0, warnings: 1 }); }); @@ -213,8 +213,8 @@ describe('host validators on mutations (MutationOptions.validators)', () => { it('checks what would be written: without the layout a new node has no shape', async () => { const file = await drawn('no-layout.bpmn', LINE); const result = await refusal(file, [{ op: 'add', kind: 'task', name: 'Inserted', after: 'A' }], { validators: [shapes], layout: false }); - // the new flow's id in the file's style (src/domain/document/ids/IdStyle.ts): LINE numbers its flows F1, F2 (the prefix), the body names the ends - expect(codes(result.errors)).toEqual(['[shapes] E_NOT_DRAWN Activity_Inserted', '[shapes] E_NOT_DRAWN F_InsertedToE']); + // the new flow's id names its ends (src/domain/document/ids/IdStyle.ts) + expect(codes(result.errors)).toEqual(['[shapes] E_NOT_DRAWN task_inserted', '[shapes] E_NOT_DRAWN flow_insertedToE']); expect((await mutateFile(file, [{ op: 'add', kind: 'task', name: 'Inserted', after: 'A' }], { validators: [shapes], dryRun: true })).validation.validators?.[0]?.errors).toEqual([]); }); @@ -225,8 +225,8 @@ describe('host validators on mutations (MutationOptions.validators)', () => { it('ruleId findings: an introduced ERROR blocks, a pre-existing one (matched by message, renames followed) does not', async () => { const file = await drawn('host.bpmn', LINE); const result = await refusal(file, [{ op: 'add', kind: 'task', after: 'A' }], { validators: [unnamedTasks] }); - // an unnamed task gets its placement as id (Activity_After, src/domain/document/ids/IdStyle.ts) - expect(result.errors).toEqual([{ code: 'naming/task', message: 'task Activity_AfterCheck has no name', validator: 'unnamedTasks', severity: 'error' }]); + // an unnamed task gets its placement as id (task_after, src/domain/document/ids/IdStyle.ts) + expect(result.errors).toEqual([{ code: 'naming/task', message: 'task task_afterCheck has no name', validator: 'unnamedTasks', severity: 'error' }]); const unnamed = await drawn('host-unnamed.bpmn', LINE.replace(' name="Check"', '')); const renamed = await mutateFile(unnamed, [{ op: 'set', id: 'A', values: { id: 'Activity_Old' } }], { validators: [{ name: 'naming', validate: unnamedTasks }] }); @@ -241,15 +241,15 @@ describe('host validators on mutations (MutationOptions.validators)', () => { const seen: Array<{ phase: string; file?: string; platform: string; ops: number; shape: boolean; doc: boolean }> = []; const spy = async (xml: string, ctx: ValidatorContext): Promise => { const doc = await ctx.doc(); - seen.push({ phase: ctx.phase, file: ctx.file, platform: ctx.platform, ops: ctx.ops.length, shape: xml.includes('bpmnElement="Activity_New"'), doc: !!doc.get('A') }); - return ctx.phase === 'after' ? [{ severity: 'warning', code: 'W_HOST', message: 'checked', element: 'Activity_New' }] : []; + seen.push({ phase: ctx.phase, file: ctx.file, platform: ctx.platform, ops: ctx.ops.length, shape: xml.includes('bpmnElement="task_new"'), doc: !!doc.get('A') }); + return ctx.phase === 'after' ? [{ severity: 'warning', code: 'W_HOST', message: 'checked', element: 'task_new' }] : []; }; const result = await mutateFile(file, [{ op: 'add', kind: 'task', name: 'New', after: 'A' }], { validators: [spy] }); expect(seen).toEqual([ { phase: 'before', file, platform: 'none', ops: 1, shape: false, doc: true }, { phase: 'after', file, platform: 'none', ops: 1, shape: true, doc: true }, ]); - expect(result.validation.warnings.at(-1)).toEqual({ code: 'W_HOST', message: 'checked', element: 'Activity_New', validator: 'spy', severity: 'warning' }); + expect(result.validation.warnings.at(-1)).toEqual({ code: 'W_HOST', message: 'checked', element: 'task_new', validator: 'spy', severity: 'warning' }); }); it('a validator that throws fails the write (E_VALIDATOR_FAILED); a bad validator is refused', async () => { @@ -275,7 +275,7 @@ describe('host validators on mutations (MutationOptions.validators)', () => { const advisory = { name: 'advice', blocks: false, validate: unnamedTasks }; const result = await mutateFile(file, [{ op: 'add', kind: 'task', after: 'A' }], { validators: [advisory], dryRun: true }); expect(result.validation.errors).toEqual([]); - expect(result.validation.warnings.at(-1)).toEqual({ code: 'naming/task', message: 'task Activity_AfterCheck has no name', validator: 'advice', severity: 'error' }); + expect(result.validation.warnings.at(-1)).toEqual({ code: 'naming/task', message: 'task task_afterCheck has no name', validator: 'advice', severity: 'error' }); expect(result.validation.validators).toEqual([{ name: 'advice', blocks: false, errors: [], warnings: [expect.objectContaining({ code: 'naming/task' })], preexisting: [], resolved: [], counts: { errors: 1, warnings: 0 } }]); // an error the file already had: no W_PREEXISTING_ERROR, only the count const unnamed = await drawn('advisory-unnamed.bpmn', LINE.replace(' name="Check"', '')); @@ -317,8 +317,8 @@ describe('validator findings in a refusal, a result and their text (library use) expect(err?.details.hint).not.toContain('--profile'); // each finding with its validator tag (validatorTag; the CLI's own refusal output is tested below) expect((err?.details['errors'] as Finding[]).map((error) => `${validatorTag(error)}${error.code} ${error.element}: ${error.message}`)).toEqual([ - '[gate] E_GATE_UNREACHABLE Activity_Loose: Activity_Loose has no incoming sequence flow', - '[gate] E_GATE_DEAD_END Activity_Loose: Activity_Loose has no outgoing sequence flow', + '[gate] E_GATE_UNREACHABLE task_loose: task_loose has no incoming sequence flow', + '[gate] E_GATE_DEAD_END task_loose: task_loose has no outgoing sequence flow', ]); expect(JSON.parse(JSON.stringify(err?.toJSON())).errors[0]).toMatchObject({ code: 'E_GATE_UNREACHABLE', validator: 'gate', severity: 'error' }); expect(readFileSync(file, 'utf8')).toBe(before); @@ -326,9 +326,9 @@ describe('validator findings in a refusal, a result and their text (library use) const inserted = await mutateFile(file, [{ op: 'add', kind: 'task', name: 'Review', after: 'A' }], { validators: [gate()] }); expect(renderMutation(mutationReport(inserted))).toContain('validator gate (the test host): 0 error(s), 0 warning(s) in the result'); const forced = await mutateFile(file, LOOSE, { validators: [gate()], force: true }); - expect(renderMutation(mutationReport(forced))).toContain('forced [gate] E_GATE_UNREACHABLE Activity_Loose'); + expect(renderMutation(mutationReport(forced))).toContain('forced [gate] E_GATE_UNREACHABLE task_loose'); // the error the file already had does not block; a result counts it instead of repeating it - const pre = mutationReport(await mutateFile(file, [{ op: 'set', id: 'Activity_Loose', values: { name: 'Still loose' } }], { validators: [gate()] })); + const pre = mutationReport(await mutateFile(file, [{ op: 'set', id: 'task_loose', values: { name: 'Still loose' } }], { validators: [gate()] })); expect(pre.warnings).toMatchObject({ added: [], preexistingCount: expect.any(Number) }); expect(pre.warnings.preexistingCount).toBeGreaterThan(0); expect(renderMutation(pre)).not.toContain('W_PREEXISTING_ERROR'); @@ -351,7 +351,7 @@ describe('findings a write leaves out (ValidatorFinding.leftOut)', () => { const file = await drawn('left-out.bpmn', DEAD_END); // the loose task's dead end is left out: only its unreachable start blocks const result = await refusal(file, LOOSE, { validators: [leftOutGate()] }); - expect(codes(result.errors)).toEqual(['[gate] E_GATE_UNREACHABLE Activity_Loose']); + expect(codes(result.errors)).toEqual(['[gate] E_GATE_UNREACHABLE task_loose']); const renamed = await mutateFile(file, [{ op: 'set', id: 'A', values: { name: 'Checked' } }], { validators: [leftOutGate()], dryRun: true }); const validator = renamed.validation.validators![0]!; @@ -369,7 +369,7 @@ describe('findings a write leaves out (ValidatorFinding.leftOut)', () => { // advisory: the line adds them after the findings the file already had; a new dead end keeps its lint warning const advisory = mutationReport(await mutateFile(file, LOOSE, { validators: [leftOutGate(false)], dryRun: true })); - expect(codes(advisory.warnings.added)).toEqual(['W_DEAD_END Activity_Loose', '[gate] E_GATE_UNREACHABLE Activity_Loose']); + expect(codes(advisory.warnings.added)).toEqual(['W_DEAD_END task_loose', '[gate] E_GATE_UNREACHABLE task_loose']); expect(renderMutation(advisory)).toContain('\nvalidator gate (the test host): 4 error(s), 0 warning(s) in the result (1 added, 0 resolved, 1 already in the file, 2 left out (dead ends the host checks elsewhere; a check lists them))\n'); // the summary counts what the file already had among the warnings, and names what was left out in a line of its own const summary = mutationSummary(advisory); diff --git a/test/e2e/api.test.ts b/test/e2e/api.test.ts index 88675714..0aa08e4f 100644 --- a/test/e2e/api.test.ts +++ b/test/e2e/api.test.ts @@ -81,10 +81,10 @@ describe('the docs/library.md example (Library use)', () => { let { xml } = await newXml({ processName: 'Order handling' }); const edit = await applyToXml(xml, [ { op: 'add', kind: 'start', name: 'Order received' }, - { op: 'add', kind: 'userTask', name: 'Check invoice', after: 'Event_OrderReceived' }, - { op: 'add', kind: 'end', name: 'Done', after: 'Activity_CheckInvoice' }, + { op: 'add', kind: 'userTask', name: 'Check invoice', after: 'startEvent_orderReceived' }, + { op: 'add', kind: 'end', name: 'Done', after: 'userTask_checkInvoice' }, ]); - expect(example).toContain("{ op: 'add', kind: 'end', name: 'Done', after: 'Activity_CheckInvoice' },"); + expect(example).toContain("{ op: 'add', kind: 'end', name: 'Done', after: 'userTask_checkInvoice' },"); if (!edit.unchanged) xml = edit.xml; const quote = (data: unknown): string => JSON.stringify(data).replaceAll('"', "'").replaceAll(',', ', '); expect(codeOf(example)).toContain(codeOf(quote(edit.result.created.map((change) => change.id)))); @@ -92,7 +92,7 @@ describe('the docs/library.md example (Library use)', () => { expect(renderMutation(edit.result)).toMatch(/^created startEvent [\s\S]*\nlayout: ok - full \(/m); const quickStart = /\$ bpmn show order\.bpmn\n([\s\S]*?)\n```/.exec(readme)?.[1]; expect(await showXml(xml)).toBe(quickStart); - const err = await rejected(applyToXml(xml, [{ op: 'add', kind: 'userTask', name: 'Ship', after: 'Activity_Check' }])); + const err = await rejected(applyToXml(xml, [{ op: 'add', kind: 'userTask', name: 'Ship', after: 'userTask_check' }])); expect(example).toContain(`err.code; // '${err.code}'`); expect(example).toContain(`err.details.candidates; // ${quote(err.details.candidates)}`); }); diff --git a/test/e2e/apply-batches.test.ts b/test/e2e/apply-batches.test.ts index adc82972..a49a75f2 100644 --- a/test/e2e/apply-batches.test.ts +++ b/test/e2e/apply-batches.test.ts @@ -49,7 +49,7 @@ const ORDER_BATCH: unknown[] = [ { op: 'connect', source: '$ship', target: '$customer', as: '$msg' }, // selectors and frames by alias: the happy path through the "no" branch, the pool order, compact of one pool { op: 'color', path: ['$start', '$end'], via: ['$no'], color: 'green' }, - { op: 'order', id: 'Collaboration_OrderHandling', pools: ['$customer', '$org'] }, + { op: 'order', id: 'collaboration_orderHandling', pools: ['$customer', '$org'] }, { op: 'compact', ids: ['$org'] }, ]; @@ -63,26 +63,26 @@ describe('apply batches: aliases, speaking ids and the format selectors together const base = await newXml({ processName: 'Order handling' }); const result = await applyToXml(base.xml, ORDER_BATCH); expect(result.result.aliases).toEqual({ - $org: 'Participant_OrderHandling', - $customer: 'Participant_Customer', - $start: 'Event_OrderReceived', - $check: 'Activity_CheckOrder', - $ok: 'Gateway_OrderOk', - $ship: 'Activity_ShipOrder', - $no: 'Flow_OrderOkToClarifyOrder', - $clarify: 'Activity_ClarifyOrder', - $wait: 'Activity_Wait', - $join: 'Gateway_OrderOk_join', - $end: 'Event_OrderHandled', - $msg: 'Flow_ShipOrderToCustomer', + $org: 'participant_orderHandling', + $customer: 'participant_customer', + $start: 'startEvent_orderReceived', + $check: 'userTask_checkOrder', + $ok: 'gateway_orderOk', + $ship: 'serviceTask_shipOrder', + $no: 'flow_orderOkToClarifyOrder', + $clarify: 'userTask_clarifyOrder', + $wait: 'task_wait', + $join: 'gateway_orderOkJoin', + $end: 'endEvent_orderHandled', + $msg: 'messageFlow_shipOrderToCustomer', }); const color = result.result.layout.format?.find((format) => format.op === 'color'); // the path through the "no" branch, flows included; the "yes" branch is not on it - expect(color?.colored).toEqual(expect.arrayContaining(['Event_OrderReceived', 'Flow_OrderOkToClarifyOrder', 'Activity_ClarifyOrder', 'Activity_Wait', 'Event_OrderHandled'])); - expect(color?.colored).not.toContain('Activity_ShipOrder'); - expect(result.result.changed.map((change) => change.id)).toContain('Collaboration_OrderHandling'); + expect(color?.colored).toEqual(expect.arrayContaining(['startEvent_orderReceived', 'flow_orderOkToClarifyOrder', 'userTask_clarifyOrder', 'task_wait', 'endEvent_orderHandled'])); + expect(color?.colored).not.toContain('serviceTask_shipOrder'); + expect(result.result.changed.map((change) => change.id)).toContain('collaboration_orderHandling'); const layout = await showXml(result.xml, { layout: true }); - expect(layout.indexOf('participant Participant_Customer')).toBeLessThan(layout.indexOf('participant Participant_OrderHandling')); + expect(layout.indexOf('participant participant_customer')).toBeLessThan(layout.indexOf('participant participant_orderHandling')); expect(result.result.layout.format?.map((format) => format.op)).toEqual(['color', 'order', 'compact']); }); @@ -97,18 +97,18 @@ describe('apply batches: aliases, speaking ids and the format selectors together it('a flow renamed after its new ends is followed by its alias into a format op', async () => { const xml = await orderModel(); - // the splice renames Flow_OrderOkToClarifyOrder (its id named its old ends); flowAs is that flow + // the splice renames flow_orderOkToClarifyOrder (its id named its old ends); flowAs is that flow const result = await applyToXml(xml, [ - { op: 'add', kind: 'task', name: 'Log reason', flow: 'Flow_OrderOkToClarifyOrder', as: '$log', flowAs: '$in' }, + { op: 'add', kind: 'task', name: 'Log reason', flow: 'flow_orderOkToClarifyOrder', as: '$log', flowAs: '$in' }, { op: 'color', ids: ['$in', '$log'], color: 'red' }, ]); - expect(result.result.aliases).toEqual({ $log: 'Activity_LogReason', $in: 'Flow_OrderOkToLogReason' }); - expect(result.result.layout.format?.[0]?.colored).toEqual(['Flow_OrderOkToLogReason', 'Activity_LogReason']); + expect(result.result.aliases).toEqual({ $log: 'task_logReason', $in: 'flow_orderOkToLogReason' }); + expect(result.result.layout.format?.[0]?.colored).toEqual(['flow_orderOkToLogReason', 'task_logReason']); // the old id names the new one - const err = await rejection(applyToXml(xml, [{ op: 'add', kind: 'task', name: 'Log reason', flow: 'Flow_OrderOkToClarifyOrder' }, { op: 'color', ids: ['Flow_OrderOkToClarifyOrder'], color: 'red' }])); + const err = await rejection(applyToXml(xml, [{ op: 'add', kind: 'task', name: 'Log reason', flow: 'flow_orderOkToClarifyOrder' }, { op: 'color', ids: ['flow_orderOkToClarifyOrder'], color: 'red' }])); expect(err.code).toBe('E_NOT_FOUND'); - expect(err.details.candidates?.[0]).toBe('Flow_OrderOkToLogReason'); - expect(err.message).toBe('No element with id "Flow_OrderOkToClarifyOrder": an op before this one renamed it to Flow_OrderOkToLogReason (its id named its old ends)'); + expect(err.details.candidates?.[0]).toBe('flow_orderOkToLogReason'); + expect(err.message).toBe('No element with id "flow_orderOkToClarifyOrder": an op before this one renamed it to flow_orderOkToLogReason (its id named its old ends)'); expect(err.details.op).toBe(1); }); }); @@ -117,35 +117,35 @@ describe('--summary: aliases and format ops', () => { it('lists the aliases and one line per format op (text and JSON)', async () => { const xml = await orderModel(); const result = await applyToXml(xml, [ - { op: 'add', kind: 'task', name: 'Archive', after: 'Event_OrderReceived', as: '$archive' }, - { op: 'place', ids: ['Activity_Wait'], below: 'Activity_ShipOrder' }, + { op: 'add', kind: 'task', name: 'Archive', after: 'startEvent_orderReceived', as: '$archive' }, + { op: 'place', ids: ['task_wait'], below: 'serviceTask_shipOrder' }, { op: 'color', ids: ['$archive'], color: 'blue' }, ]); const summary = mutationSummary(result.result); - expect(summary.aliases).toEqual({ $archive: 'Activity_Archive' }); + expect(summary.aliases).toEqual({ $archive: 'task_archive' }); expect(summary.format?.map((format) => format.op)).toEqual(['place', 'color']); const text = renderSummary(summary); - expect(text).toMatch(/^aliases: \$archive = Activity_Archive$/m); - expect(text).toMatch(/^format color #2: colored Activity_Archive$/m); + expect(text).toMatch(/^aliases: \$archive = task_archive$/m); + expect(text).toMatch(/^format color #2: colored task_archive$/m); expect(text).toMatch(/^format place #1: /m); }); it('names the old id of a flow the change renamed after its new ends (text and JSON)', async () => { const xml = await orderModel(); const result = await applyToXml(xml, [ - { op: 'add', kind: 'task', name: 'Log reason', flow: 'Flow_OrderOkToClarifyOrder' }, - { op: 'add', kind: 'task', name: 'Notify', after: 'Activity_LogReason' }, + { op: 'add', kind: 'task', name: 'Log reason', flow: 'flow_orderOkToClarifyOrder' }, + { op: 'add', kind: 'task', name: 'Notify', after: 'task_logReason' }, ]); - // Flow_OrderOkToClarifyOrder -> Flow_OrderOkToLogReason (splice); Flow_LogReasonToClarifyOrder (new) -> Flow_LogReasonToNotify (second splice) - expect(result.result.renamed).toEqual({ Flow_OrderOkToClarifyOrder: 'Flow_OrderOkToLogReason' }); + // flow_orderOkToClarifyOrder -> flow_orderOkToLogReason (splice); flow_logReasonToClarifyOrder (new) -> flow_logReasonToNotify (second splice) + expect(result.result.renamed).toEqual({ flow_orderOkToClarifyOrder: 'flow_orderOkToLogReason' }); const summary = mutationSummary(result.result); - expect(summary.renamed).toEqual({ Flow_OrderOkToClarifyOrder: 'Flow_OrderOkToLogReason' }); - expect(renderSummary(summary)).toMatch(/^renamed: Flow_OrderOkToClarifyOrder -> Flow_OrderOkToLogReason$/m); + expect(summary.renamed).toEqual({ flow_orderOkToClarifyOrder: 'flow_orderOkToLogReason' }); + expect(renderSummary(summary)).toMatch(/^renamed: flow_orderOkToClarifyOrder -> flow_orderOkToLogReason$/m); const chain = await applyToXml(xml, [ - { op: 'add', kind: 'task', name: 'Log reason', flow: 'Flow_OrderOkToClarifyOrder' }, - { op: 'add', kind: 'task', name: 'Prepare', before: 'Activity_LogReason' }, + { op: 'add', kind: 'task', name: 'Log reason', flow: 'flow_orderOkToClarifyOrder' }, + { op: 'add', kind: 'task', name: 'Prepare', before: 'task_logReason' }, ]); - expect(chain.result.renamed).toEqual({ Flow_OrderOkToClarifyOrder: 'Flow_OrderOkToPrepare' }); + expect(chain.result.renamed).toEqual({ flow_orderOkToClarifyOrder: 'flow_orderOkToPrepare' }); }); it('a format-only change is not "no changes": the line says what moved, or why nothing did', async () => { @@ -176,19 +176,19 @@ const C8_MODELER = ` - + `; describe('Camunda 8 with speaking ids and the reading views', () => { - it('a new event definition gets a speaking id in the file\'s style, never the hash of its event', async () => { + it('a new event definition gets a speaking id, never the hash of its event', async () => { const result = await applyToXml(C8_MODELER, [{ op: 'retype', id: 'Event_1q2w3e4', kind: 'intermediateCatchEvent:conditional', when: '= stock > 0' }], { layout: false }); - expect(result.xml).toContain(''); - expect(result.xml).not.toContain('ConditionalEventDefinition_1q2w3e4'); + expect(result.xml).toContain(''); + expect(result.xml).not.toContain('EventDefinition_1q2w3e4'); }); - it('a taken definition id gets _2 and W_ID_SUFFIXED like every generated id', async () => { + it('a taken definition id gets a number suffix and W_ID_SUFFIXED like every generated id', async () => { const result = await applyToXml( C8_MODELER, [ @@ -196,9 +196,9 @@ describe('Camunda 8 with speaking ids and the reading views', () => { ], { layout: false }, ); - expect(result.xml).toContain(''); + expect(result.xml).toContain(''); const warning = result.result.warnings.added.find((warning) => warning.code === 'W_ID_SUFFIXED'); - expect(warning?.element).toBe('ConditionalEventDefinition_StockReadyAgain_2'); + expect(warning?.element).toBe('conditionalEventDefinition_stockReadyAgain2'); }); it('show --around and show --context print each zeebe setting once', async () => { @@ -211,12 +211,12 @@ describe('Camunda 8 with speaking ids and the reading views', () => { { op: 'ext', id: '$charge', action: 'add', type: 'zeebe:taskDefinition', attrs: { type: 'charge-card', retries: '3' } }, { op: 'ext', id: '$review', action: 'add', type: 'zeebe:assignmentDefinition', attrs: { candidateGroups: 'sales' } }, ]); - const around = await showXml(result.xml, { around: 'Activity_Charge' }); - expect(around).toContain('serviceTask Activity_Charge "Charge" [zeebe:taskDefinition type=charge-card retries=3]'); - expect(around).toContain('userTask Activity_Review "Review" [zeebe:userTask, zeebe:assignmentDefinition candidateGroups=sales]'); + const around = await showXml(result.xml, { around: 'serviceTask_charge' }); + expect(around).toContain('serviceTask serviceTask_charge "Charge" [zeebe:taskDefinition type=charge-card retries=3]'); + expect(around).toContain('userTask userTask_review "Review" [zeebe:userTask, zeebe:assignmentDefinition candidateGroups=sales]'); expect(around).not.toContain('job='); - const context = await showXml(result.xml, { id: 'Activity_Review', context: true }); - expect(context).toMatch(/^userTask Activity_Review "Review"$/m); + const context = await showXml(result.xml, { id: 'userTask_review', context: true }); + expect(context).toMatch(/^userTask userTask_review "Review"$/m); expect(context).toContain('implementation: zeebe:userTask, zeebe:assignmentDefinition candidateGroups=sales'); // the full show keeps the short facts (it has no extension summary) expect(await showXml(result.xml)).toContain('[job=charge-card, ext: zeebe:taskDefinition]'); @@ -233,10 +233,10 @@ describe('Camunda 8 with speaking ids and the reading views', () => { const one = await applyToXml(base.xml, [ { op: 'add', kind: 'start', name: 'Received', as: '$start' }, { op: 'add', kind: 'serviceTask', name: 'Charge', after: '$start' }, - { op: 'add', kind: 'end', name: 'Done', after: 'Activity_Charge' }, + { op: 'add', kind: 'end', name: 'Done', after: 'serviceTask_charge' }, ]); expect(one.result.warnings.added.map((warning) => warning.code)).toContain('W_C8_DEPLOY_IMPLEMENTATION'); - const two = await applyToXml(one.xml, [{ op: 'ext', id: 'Activity_Charge', action: 'add', type: 'zeebe:taskDefinition', attrs: { type: 'charge-card' } }]); + const two = await applyToXml(one.xml, [{ op: 'ext', id: 'serviceTask_charge', action: 'add', type: 'zeebe:taskDefinition', attrs: { type: 'charge-card' } }]); expect(two.result.warnings.added).toEqual([]); expect(two.result.warnings.resolved.map((warning) => warning.code)).toContain('W_C8_DEPLOY_IMPLEMENTATION'); expect(renderSummary(mutationSummary(two.result))).toMatch(/^warnings: 0 added, 1 resolved, 0 already in the file$/m); diff --git a/test/e2e/bpmnlint-cli.test.ts b/test/e2e/bpmnlint-cli.test.ts index 6b71b272..237ae6ab 100644 --- a/test/e2e/bpmnlint-cli.test.ts +++ b/test/e2e/bpmnlint-cli.test.ts @@ -34,11 +34,11 @@ describe('the CLI under plain node (src/ built into a temporary directory)', () }; it('validate runs the .bpmnlintrc (exit 2 on its errors); --no-lint-config and --lint-config; stdin from the working directory; JSON', async () => { - const xml = await model([...LINE, { op: 'add', kind: 'task', after: 'Activity_Check' }]); + const xml = await model([...LINE, { op: 'add', kind: 'task', after: 'userTask_check' }]); const dir = repo({ extends: 'bpmnlint:recommended' }, { 'models/x.bpmn': xml, 'lint/strict.json': JSON.stringify({ rules: { 'label-required': 'warn' } }) }); const validated = bpmn(dir, ['validate', 'models/x.bpmn']); expect(validated.code, validated.err).toBe(2); - expect(validated.out).toContain('[bpmnlint error] label-required Activity_AfterCheck: Element is missing label/name\n'); + expect(validated.out).toContain('[bpmnlint error] label-required task_afterCheck: Element is missing label/name\n'); expect(validated.out).toContain('validator bpmnlint (.bpmnlintrc in .): 1 error(s), 0 warning(s) in the file\n'); const off = bpmn(dir, ['validate', 'models/x.bpmn', '--no-lint-config']); expect(off.code).toBe(0); @@ -47,13 +47,13 @@ describe('the CLI under plain node (src/ built into a temporary directory)', () expect(other.code).toBe(0); expect(other.out).toContain('validator bpmnlint (--lint-config ../lint/strict.json): 0 error(s), 1 warning(s) in the file\n'); // a rule set to warn: no severity in the tag - expect(other.out).toContain('[bpmnlint] label-required Activity_AfterCheck: Element is missing label/name\n'); + expect(other.out).toContain('[bpmnlint] label-required task_afterCheck: Element is missing label/name\n'); const stdin = bpmn(dir, ['validate', '-'], xml); expect(stdin.code).toBe(2); - expect(stdin.out).toContain('[bpmnlint error] label-required Activity_AfterCheck'); + expect(stdin.out).toContain('[bpmnlint error] label-required task_afterCheck'); const json = JSON.parse(bpmn(dir, ['validate', 'models/x.bpmn', '--json']).out); expect(json.validators[0]).toMatchObject({ name: 'bpmnlint', detail: '.bpmnlintrc in .', blocks: false, counts: { errors: 1, warnings: 0 } }); - expect(json.errors).toContainEqual({ code: 'label-required', message: 'Element is missing label/name', element: 'Activity_AfterCheck', validator: 'bpmnlint', severity: 'error' }); + expect(json.errors).toContainEqual({ code: 'label-required', message: 'Element is missing label/name', element: 'task_afterCheck', validator: 'bpmnlint', severity: 'error' }); const missing = bpmn(dir, ['validate', 'models/x.bpmn', '--lint-config', 'nope.json']); expect(missing.code).toBe(4); expect(missing.err).toMatch(/^error E_LINT_CONFIG: /); @@ -61,14 +61,14 @@ describe('the CLI under plain node (src/ built into a temporary directory)', () // a CI `container:` job, `docker run -v`: root over a checkout of another uid (simulated: a preload makes process.getuid() 0) it.skipIf(!process.getuid || process.getuid() === 0)('root over a checkout of another uid: the .bpmnlintrc at the repository root runs with bpmn-cli\'s rules (exit 2 on an error rule), the project plugin is left out and named', async () => { - const dir = repo({ extends: ['bpmnlint:recommended', 'plugin:evil/recommended'] }, { 'models/x.bpmn': await model([...LINE, { op: 'add', kind: 'task', after: 'Activity_Check' }]) }); + const dir = repo({ extends: ['bpmnlint:recommended', 'plugin:evil/recommended'] }, { 'models/x.bpmn': await model([...LINE, { op: 'add', kind: 'task', after: 'userTask_check' }]) }); const marker = join(dir, 'RAN.txt'); plugin(dir, 'bpmnlint-plugin-evil', { main: `require('fs').writeFileSync(${JSON.stringify(marker)}, 'ran'); module.exports = { configs: { recommended: { rules: {} } } };` }); const asRoot = join(top, 'as-root.cjs'); writeFileSync(asRoot, 'process.getuid = () => 0; delete process.env.SUDO_UID;\n'); const spawned = spawnSync(process.execPath, ['--require', asRoot, cli, 'validate', 'x.bpmn'], { cwd: join(dir, 'models'), encoding: 'utf8', input: '' }); expect(spawned.status, spawned.stderr).toBe(2); - expect(spawned.stdout).toContain('[bpmnlint error] label-required Activity_AfterCheck: Element is missing label/name\n'); + expect(spawned.stdout).toContain('[bpmnlint error] label-required task_afterCheck: Element is missing label/name\n'); expect(spawned.stdout).toContain("[bpmnlint] W_LINT_UNAVAILABLE: plugin:evil/recommended left out: bpmnlint-plugin-evil is not bundled with bpmn-cli, and bpmn-cli runs project plugins only in a repository of the user running it (the repository of ../.git belongs to another user) (Check who created that repository and its .git; run bpmn-cli as its owner (git's safe.directory does not change this), or remove the plugin from the configuration; --no-lint-config skips bpmnlint.)\n"); expect(spawned.stdout).toContain('validator bpmnlint (.bpmnlintrc in ..; 1 left out (W_LINT_UNAVAILABLE; bpmn validate lists it)): 1 error(s), 1 warning(s) in the file\n'); expect(existsSync(marker)).toBe(false); @@ -81,13 +81,13 @@ describe('the CLI under plain node (src/ built into a temporary directory)', () it('a write exits 0 and lists the findings it added (5 with --strict); the hint and the validator line name the file relative to the working directory', async () => { const dir = repo({ extends: 'bpmnlint:recommended' }, { 'models/x.bpmn': await model(LINE) }); - const add = bpmn(join(dir, 'models'), ['add', 'x.bpmn', 'task', '--after', 'Activity_Check', '--dry-run']); + const add = bpmn(join(dir, 'models'), ['add', 'x.bpmn', 'task', '--after', 'userTask_check', '--dry-run']); expect(add.code, add.err).toBe(0); - expect(add.out).toContain('warning [bpmnlint error] label-required Activity_AfterCheck: Element is missing label/name\n'); + expect(add.out).toContain('warning [bpmnlint error] label-required task_afterCheck: Element is missing label/name\n'); expect(add.out).toContain('validator bpmnlint (.bpmnlintrc in ..): 1 error(s), 0 warning(s) in the result (1 added, 0 resolved, 0 already in the file)\n'); - expect(bpmn(join(dir, 'models'), ['add', 'x.bpmn', 'task', '--after', 'Activity_Check', '--dry-run', '--strict']).code).toBe(5); - const summary = bpmn(dir, ['add', 'models/x.bpmn', 'task', '--after', 'Activity_Check', '--dry-run', '--summary']); - expect(summary.out).toContain('warning [bpmnlint error] label-required Activity_AfterCheck: Element is missing label/name\n'); + expect(bpmn(join(dir, 'models'), ['add', 'x.bpmn', 'task', '--after', 'userTask_check', '--dry-run', '--strict']).code).toBe(5); + const summary = bpmn(dir, ['add', 'models/x.bpmn', 'task', '--after', 'userTask_check', '--dry-run', '--summary']); + expect(summary.out).toContain('warning [bpmnlint error] label-required task_afterCheck: Element is missing label/name\n'); }); it('JSON beyond 64 KB reaches a pipe whole with exit 2 and 5 (validate, --strict, a write with --strict): the exit code does not cut it off', async () => { @@ -103,7 +103,7 @@ describe('the CLI under plain node (src/ built into a temporary directory)', () }; expect(piped(['validate', 'x.bpmn', '--json'], 2).errors).toHaveLength(300); piped(['validate', 'x.bpmn', '--json', '--strict', '--lint-config', 'lint/warn.json'], 5); - piped(['add', 'x.bpmn', 'task', '--after', 'Activity_Check', '--dry-run', '--json', '--strict'], 5); + piped(['add', 'x.bpmn', 'task', '--after', 'userTask_check', '--dry-run', '--json', '--strict'], 5); // a reader that closes after the first chunk (`| head`): the exit code stays, no EPIPE trace on stderr const early = (args: string[]) => new Promise<{ code: number | null; err: string }>((done) => { @@ -116,7 +116,7 @@ describe('the CLI under plain node (src/ built into a temporary directory)', () expect(await early(['validate', 'x.bpmn', '--json'])).toEqual({ code: 2, err: '' }); expect(await early(['validate', 'x.bpmn'])).toEqual({ code: 2, err: '' }); expect(await early(['validate', 'x.bpmn', '--strict', '--lint-config', 'lint/warn.json'])).toEqual({ code: 5, err: '' }); - expect(await early(['add', 'x.bpmn', 'task', '--after', 'Activity_Check', '--dry-run', '--json', '--strict'])).toEqual({ code: 5, err: '' }); + expect(await early(['add', 'x.bpmn', 'task', '--after', 'userTask_check', '--dry-run', '--json', '--strict'])).toEqual({ code: 5, err: '' }); }); it('a project plugin that leaves a timer running does not keep the command alive', async () => { @@ -125,7 +125,7 @@ describe('the CLI under plain node (src/ built into a temporary directory)', () main: "setInterval(() => {}, 1000); module.exports = { configs: { recommended: { rules: { slow: 'error' } } } };", rules: { slow: "module.exports = () => ({ check(node, reporter) { if (node.$type === 'bpmn:Process') reporter.report(node.id, 'slow'); return new Promise((done) => setTimeout(done, 60000)); } });" }, }); - for (const [args, code] of [[['validate', 'x.bpmn'], 2], [['add', 'x.bpmn', 'task', '--after', 'Activity_Check', '--dry-run'], 0]] as const) { + for (const [args, code] of [[['validate', 'x.bpmn'], 2], [['add', 'x.bpmn', 'task', '--after', 'userTask_check', '--dry-run'], 0]] as const) { const started = Date.now(); const spawned = spawnSync(process.execPath, [cli, ...args], { cwd: dir, encoding: 'utf8', input: '', timeout: 20000 }); expect(spawned.signal, args.join(' ')).toBeNull(); @@ -135,7 +135,7 @@ describe('the CLI under plain node (src/ built into a temporary directory)', () }, 60000); it.skipIf(process.platform === 'win32')('a working directory that was removed: validate and a write on an absolute path still run, the lookup from the file', async () => { - const dir = repo({ extends: 'bpmnlint:recommended' }, { 'x.bpmn': await model([...LINE, { op: 'add', kind: 'task', after: 'Activity_Check' }]) }); + const dir = repo({ extends: 'bpmnlint:recommended' }, { 'x.bpmn': await model([...LINE, { op: 'add', kind: 'task', after: 'userTask_check' }]) }); const gone = join(top, 'gone-cwd'); const inGone = (args: string[]) => { mkdirSync(gone, { recursive: true }); @@ -144,15 +144,15 @@ describe('the CLI under plain node (src/ built into a temporary directory)', () }; const validated = inGone(['validate', join(dir, 'x.bpmn')]); expect(validated.code, validated.err).toBe(2); - expect(validated.out).toContain('[bpmnlint error] label-required Activity_AfterCheck'); + expect(validated.out).toContain('[bpmnlint error] label-required task_afterCheck'); const off = inGone(['validate', join(dir, 'x.bpmn'), '--no-lint-config']); expect(off.code, off.err).toBe(0); - const add = inGone(['add', join(dir, 'x.bpmn'), 'task', 'Ship', '--after', 'Activity_AfterCheck', '--dry-run']); + const add = inGone(['add', join(dir, 'x.bpmn'), 'task', 'Ship', '--after', 'task_afterCheck', '--dry-run']); expect(add.code, add.err).toBe(0); }); it.skipIf(process.platform === 'win32')('a model reached through a linked directory from outside its repository runs the repository\'s .bpmnlintrc; a link inside a repository keeps that repository\'s', async () => { - const xml = await model([...LINE, { op: 'add', kind: 'task', after: 'Activity_Check' }]); + const xml = await model([...LINE, { op: 'add', kind: 'task', after: 'userTask_check' }]); const dir = repo({ extends: 'bpmnlint:recommended' }, { 'models/x.bpmn': xml }); const outside = join(top, 'cli-links'); mkdirSync(join(outside, 'ext'), { recursive: true }); @@ -161,7 +161,7 @@ describe('the CLI under plain node (src/ built into a temporary directory)', () symlinkSync(join(outside, 'ext'), join(dir, 'extlink'), 'dir'); const linked = bpmn(outside, ['validate', 'mlink/x.bpmn']); expect(linked.code, linked.err).toBe(2); - expect(linked.out).toContain('[bpmnlint error] label-required Activity_AfterCheck: Element is missing label/name\n'); + expect(linked.out).toContain('[bpmnlint error] label-required task_afterCheck: Element is missing label/name\n'); expect(linked.out).toContain(`validator bpmnlint (.bpmnlintrc in ../${basename(dir)}): 1 error(s), 0 warning(s) in the file\n`); // a new file in a subdirectory the write creates behind the link: the same .bpmnlintrc const created = bpmn(outside, ['new', 'mlink/sub/n.bpmn', '--name', 'Order']); @@ -176,7 +176,7 @@ describe('the CLI under plain node (src/ built into a temporary directory)', () it('a write looks the .bpmnlintrc up from the file it writes (-o); a model from stdin without -o from the working directory', async () => { const xml = await model(LINE); const dir = repo(undefined, { 'plain/x.bpmn': xml, 'sub/.bpmnlintrc': JSON.stringify({ extends: 'bpmnlint:recommended' }) }); - const add = ['add', '-', 'task', '--after', 'Activity_Check']; + const add = ['add', '-', 'task', '--after', 'userTask_check']; expect(bpmn(dir, [...add, '-o', 'sub/out.bpmn'], xml).out).toContain('validator bpmnlint (.bpmnlintrc in sub): 1 error(s)'); // the XML on stdout, the report on stderr const here = bpmn(dir, add, xml); @@ -184,8 +184,8 @@ describe('the CLI under plain node (src/ built into a temporary directory)', () expect(here.err).not.toContain('bpmnlint'); expect(bpmn(join(dir, 'sub'), add, xml).err).toContain('validator bpmnlint (.bpmnlintrc in .): 1 error(s)'); // a file written elsewhere: the target's, not the input's - expect(bpmn(dir, ['add', 'plain/x.bpmn', 'task', '--after', 'Activity_Check', '-o', 'sub/y.bpmn']).out).toContain('validator bpmnlint (.bpmnlintrc in sub): 1 error(s)'); - expect(bpmn(dir, ['add', 'sub/y.bpmn', 'task', 'Ship', '--after', 'Activity_AfterCheck', '-o', 'plain/z.bpmn']).out).not.toContain('bpmnlint'); + expect(bpmn(dir, ['add', 'plain/x.bpmn', 'task', '--after', 'userTask_check', '-o', 'sub/y.bpmn']).out).toContain('validator bpmnlint (.bpmnlintrc in sub): 1 error(s)'); + expect(bpmn(dir, ['add', 'sub/y.bpmn', 'task', 'Ship', '--after', 'task_afterCheck', '-o', 'plain/z.bpmn']).out).not.toContain('bpmnlint'); }); it('without a .bpmnlintrc nothing of bpmnlint is loaded and the output is that of --no-lint-config', async () => { @@ -197,7 +197,7 @@ describe('the CLI under plain node (src/ built into a temporary directory)', () expect(run(dir)).toBe('0'); const withRc = repo({ extends: 'bpmnlint:recommended' }, { 'x.bpmn': await model(LINE) }); expect(Number(run(withRc))).toBeGreaterThan(30); - for (const args of [['validate', 'x.bpmn'], ['add', 'x.bpmn', 'task', '--after', 'Activity_Check', '--dry-run']]) { + for (const args of [['validate', 'x.bpmn'], ['add', 'x.bpmn', 'task', '--after', 'userTask_check', '--dry-run']]) { expect(bpmn(dir, args).out).toBe(bpmn(dir, [...args, '--no-lint-config']).out); } }); @@ -225,10 +225,10 @@ describe('the CLI under plain node (src/ built into a temporary directory)', () expect(validated.code, validated.err).toBe(0); expect(validated.out).toContain('[bpmnlint] W_LINT_UNAVAILABLE: bpmnlint could not be loaded: the plugin is broken; the plugin found bpmnlint 11.15.0 in '); expect(validated.out).toContain('validator bpmnlint (.bpmnlintrc in .; bpmnlint did not run (bpmn validate lists why)): 0 error(s), 1 warning(s) in the file\n'); - const add = bpmn(dir, ['add', 'x.bpmn', 'task', 'Ship', '--after', 'Activity_Check'], undefined, bin); + const add = bpmn(dir, ['add', 'x.bpmn', 'task', 'Ship', '--after', 'userTask_check'], undefined, bin); expect(add.code, add.err).toBe(0); expect(add.out).toContain('written: x.bpmn'); - expect(readFileSync(join(dir, 'x.bpmn'), 'utf8')).toContain('Activity_Ship'); + expect(readFileSync(join(dir, 'x.bpmn'), 'utf8')).toContain('task_ship'); }); it('a project bpmnlint hoisted next to the plugin: an old one is named (the plugin cannot load), a loadable other version too', async () => { @@ -307,7 +307,7 @@ describe('the CLI under plain node (src/ built into a temporary directory)', () expect(bpmn(dir, ['new', 'models/a.bpmn', '--name', 'Invoice handling']).code).toBe(0); expect(bpmn(dir, ['new', 'models/b.bpmn', '--name', 'Invoice handling', '--no-lint-config']).code).toBe(0); expect(readFileSync(join(dir, 'models', 'a.bpmn'), 'utf8')).toBe(readFileSync(join(dir, 'models', 'b.bpmn'), 'utf8')); - expect(readFileSync(join(dir, 'models', 'a.bpmn'), 'utf8')).toContain('_{name:camel} lint task userTask serviceTask scriptTask sendTask receiveTask manualTask businessRuleTask subProcess callActivity startEvent endEvent', + ' _{name:camel} lint task userTask serviceTask scriptTask sendTask receiveTask manualTask businessRuleTask subProcess callActivity startEvent endEvent participant lane dataObject dataStore textAnnotation group collaboration', ]); const json = JSON.parse(bpmn(dir, ['show', 'models/a.bpmn', '--conventions', '--json']).out); expect(json).toMatchObject({ file: 'models/a.bpmn', exists: true, source: '.bpmnlintrc in .', rule: { severity: 'warn', case: 'camelCase', qualifier: 'optional' }, notes: [] }); - expect(bpmn(dir, ['show', 'models/a.bpmn', '--conventions', '--no-lint-config']).out.split('\n')[1]).toBe("no id conventions: --no-lint-config leaves the .bpmnlintrc out; new ids follow the file's id style"); + expect(bpmn(dir, ['show', 'models/a.bpmn', '--conventions', '--no-lint-config']).out.split('\n')[1]).toBe("no id conventions: --no-lint-config leaves the .bpmnlintrc out; new ids follow element-id-naming's defaults (case camelCase)"); // the .bpmnlintrc only decides the conventions: without --conventions its options are an error for (const flag of [['--lint-config', 'nope.json'], ['--no-lint-config']]) { const plain = bpmn(dir, ['show', 'models/a.bpmn', ...flag]); @@ -334,19 +334,19 @@ describe('the CLI under plain node (src/ built into a temporary directory)', () expect(bpmn(dir, ['show', 'models/a.bpmn', '--conventions', '--lint-config', 'nope.json']).code).toBe(4); const stdin = bpmn(dir, ['show', '-', '--conventions'], readFileSync(join(dir, 'models', 'a.bpmn'), 'utf8')); expect(stdin.out.split('\n').slice(0, 2)).toEqual(['conventions of the model', 'element-id-naming (.bpmnlintrc in .): warn, case camelCase, eventDefinitionQualifier optional']); - const usage = bpmn(dir, ['show', 'models/a.bpmn', 'Process_InvoiceHandling', '--conventions', '--layout']); + const usage = bpmn(dir, ['show', 'models/a.bpmn', 'invoiceHandling', '--conventions', '--layout']); expect(usage.code).toBe(1); expect(usage.err).toContain('error E_USAGE: --conventions shows the id conventions of the whole file; drop the element id and --layout'); // a .bpmnlintrc that is not strict JSON, one that leaves the rule out (a typo): the reason and the W_LINT_UNAVAILABLE const broken = repo('{"extends":["plugin:@miragon/rules/recommended-for-automation"],}'); const notJson = bpmn(broken, ['show', 'x.bpmn', '--conventions']); expect(notJson.code, notJson.err).toBe(0); - expect(notJson.out.split('\n')[1]).toBe("no id conventions: the .bpmnlintrc in . could not be loaded (W_LINT_UNAVAILABLE below); new ids follow the file's id style"); + expect(notJson.out.split('\n')[1]).toBe("no id conventions: the .bpmnlintrc in . could not be loaded (W_LINT_UNAVAILABLE below); new ids follow element-id-naming's defaults (case camelCase)"); expect(notJson.out).toMatch(/^warning W_LINT_UNAVAILABLE: \.bpmnlintrc is not JSON /m); expect(JSON.parse(bpmn(broken, ['show', 'x.bpmn', '--conventions', '--json']).out)).toMatchObject({ source: '.bpmnlintrc in .', undeclared: 'not loaded', notes: [{ code: 'W_LINT_UNAVAILABLE' }] }); const typo = repo({ rules: { 'miragon/element-id-naming': 'error' } }); const left = bpmn(typo, ['show', 'x.bpmn', '--conventions']); - expect(left.out.split('\n')[1]).toBe("no id conventions: element-id-naming is off or not configured in the .bpmnlintrc in .; new ids follow the file's id style"); + expect(left.out.split('\n')[1]).toBe("no id conventions: element-id-naming is off or not configured in the .bpmnlintrc in .; new ids follow element-id-naming's defaults (case camelCase)"); expect(left.out).toContain('warning W_LINT_UNAVAILABLE: miragon/element-id-naming left out: bpmnlint-plugin-miragon is not installed'); // validate - (stdin) looks the .bpmnlintrc up from the working directory: its W_CONFIG_IGNORED too const odd = repo({ rules: { '@miragon/rules/element-id-naming': ['warn', { eventDefinitionQualifier: 'requried' }] } }); diff --git a/test/e2e/camunda7.test.ts b/test/e2e/camunda7.test.ts index 81628fd7..034c6146 100644 --- a/test/e2e/camunda7.test.ts +++ b/test/e2e/camunda7.test.ts @@ -288,13 +288,13 @@ describe('apply: "message" on add ops for send and receive tasks (#11)', () => { const ops = parseOps([ { op: 'add', kind: 'startEvent', id: 'Event_Start' }, { op: 'add', kind: 'receiveTask', name: 'Wait payment', after: 'Event_Start', message: 'PaymentReceived' }, - { op: 'add', kind: 'sendTask', name: 'Send invoice', after: 'Activity_WaitPayment', message: 'Invoice' }, + { op: 'add', kind: 'sendTask', name: 'Send invoice', after: 'receiveTask_waitPayment', message: 'Invoice' }, ]); const doc = Doc.create({ target: 'camunda7' }); const cs = runOps(doc, ops); expect(cs.warnings.map((warning) => warning.code)).not.toContain('W_OPTION_IGNORED'); - expect(doc.require('Activity_WaitPayment').get('messageRef').get('name')).toBe('PaymentReceived'); - expect(doc.require('Activity_SendInvoice').get('messageRef').get('name')).toBe('Invoice'); + expect(doc.require('receiveTask_waitPayment').get('messageRef').get('name')).toBe('PaymentReceived'); + expect(doc.require('sendTask_sendInvoice').get('messageRef').get('name')).toBe('Invoice'); expect(() => parseOps([{ op: 'add', kind: 'userTask', message: 'X' }])).toThrow(/only apply to events/); }); }); @@ -333,7 +333,7 @@ describe('find shows the matched vendor value; kinds documents the C7 keys (#8, writeFileSync(file, await doc.toXml()); const cliRun = cli('find', file, 'charge-card'); expect(cliRun.code).toBe(0); - expect(cliRun.out.trim()).toBe('serviceTask Activity_Charge "Charge" (in p) [camunda:topic=charge-card]'); + expect(cliRun.out.trim()).toBe('serviceTask serviceTask_charge "Charge" (in p) [camunda:topic=charge-card]'); }); it('kinds: --message on send / receive tasks, the set key groups and the nested keys', () => { diff --git a/test/e2e/cli-commands.test.ts b/test/e2e/cli-commands.test.ts index 78cafb3b..7dad448e 100644 --- a/test/e2e/cli-commands.test.ts +++ b/test/e2e/cli-commands.test.ts @@ -39,11 +39,11 @@ describe('Cli in-process', () => { const file = join(dir, 'order.bpmn'); expect((await bpmn(['new', file, '--name', 'Order', '--no-lint-config'])).code).toBe(0); expect((await bpmn(['add', file, 'startEvent', 'Start', '--no-lint-config'])).code).toBe(0); - const added = await bpmn(['add', file, 'userTask', 'Check invoice', '--after', 'Event_Start', '--summary', '--json', '--no-lint-config']); + const added = await bpmn(['add', file, 'userTask', 'Check invoice', '--after', 'startEvent_start', '--summary', '--json', '--no-lint-config']); expect(added.code).toBe(0); - expect(JSON.parse(added.out).created).toEqual({ userTask: ['Activity_CheckInvoice'], sequenceFlow: [expect.any(String)] }); + expect(JSON.parse(added.out).created).toEqual({ userTask: ['userTask_checkInvoice'], sequenceFlow: [expect.any(String)] }); const shown = await bpmn(['show', '-', '--json'], readFileSync(file, 'utf8')); - expect(JSON.stringify(JSON.parse(shown.out))).toContain('Activity_CheckInvoice'); + expect(JSON.stringify(JSON.parse(shown.out))).toContain('userTask_checkInvoice'); const twice = await bpmn(['apply', '-', '-'], readFileSync(file, 'utf8')); expect(twice.code).toBe(1); expect(twice.err).toContain('error E_USAGE: the model and the ops cannot both come from stdin'); diff --git a/test/e2e/cli-format.test.ts b/test/e2e/cli-format.test.ts index 4462d2af..67c717e2 100644 --- a/test/e2e/cli-format.test.ts +++ b/test/e2e/cli-format.test.ts @@ -178,7 +178,7 @@ describe('read side', () => { bpmn('color', file, 'A', '--color', 'blue'); const cliRun = bpmn('show', file, '--layout'); expect(cliRun.code, cliRun.err).toBe(0); - expect(cliRun.out).toMatch(/^diagram BPMNPlane_(Collaboration_P) \(\1\)\n {2}columns: c0\.\.c4\n {2}participant Pool "Org"\n {4}lane L1 "Clerk"\n {6}row 1: c0 S, c1 A, c2 G, c3 B, c4 E\n {6}row 2: c3 C\n {6}row 3: c4 E2\n {4}lane L2 "Boss"\n/); + expect(cliRun.out).toMatch(/^diagram BPMNPlane_(collaboration_p) \(\1\)\n {2}columns: c0\.\.c4\n {2}participant Pool "Org"\n {4}lane L1 "Clerk"\n {6}row 1: c0 S, c1 A, c2 G, c3 B, c4 E\n {6}row 2: c3 C\n {6}row 3: c4 E2\n {4}lane L2 "Boss"\n/); expect(cliRun.out).toMatch(/^colors: A blue$/m); expect(cliRun.out).toMatch(/^layout quality: score \d+: .*overlaps 1/m); expect(cliRun.out).toMatch(/^ {2}overlaps \[B, C\]$/m); diff --git a/test/e2e/cli-layout.test.ts b/test/e2e/cli-layout.test.ts index 0304c4bb..e8265690 100644 --- a/test/e2e/cli-layout.test.ts +++ b/test/e2e/cli-layout.test.ts @@ -68,7 +68,7 @@ describe('bpmn layout modes on the command line', () => { const cliRun = bpmn('add', file, 'userTask', 'Review', '--after', 'A'); expect(cliRun.code, cliRun.err).toBe(0); expect(cliRun.out).toMatch(/^layout: ok - incremental \(hand-made diagram: kept, changes placed locally\)$/m); - expect(cliRun.out).toMatch(/^ {2}placed: Activity_Review, Flow_ReviewToDone$/m); + expect(cliRun.out).toMatch(/^ {2}placed: userTask_review, flow_reviewToDone$/m); expect(cliRun.out).toMatch(/^layout quality: score 0 -> 0$/m); expect(await boxOf(file, 'S')).toEqual(before); }); @@ -78,7 +78,7 @@ describe('bpmn layout modes on the command line', () => { const cliRun = bpmn('add', file, 'task', 'Second', '--after', 'A', '--json'); expect(cliRun.code, cliRun.err).toBe(0); const json = JSON.parse(cliRun.out); - expect(json.layout).toMatchObject({ status: 'ok', mode: 'incremental', placed: ['Activity_Second', expect.stringMatching(/^Flow_/)], rerouted: [expect.any(String)], pruned: [] }); + expect(json.layout).toMatchObject({ status: 'ok', mode: 'incremental', placed: ['task_second', expect.stringMatching(/^flow_/)], rerouted: [expect.any(String)], pruned: [] }); expect(json.layout.metrics).toMatchObject({ before: { score: 0 }, after: { score: 0 }, added: [], resolved: [] }); expect(json.layout.metrics.after.counts).toHaveProperty('crossings', 0); }); diff --git a/test/e2e/cli-views.test.ts b/test/e2e/cli-views.test.ts index 2d99867a..3fa790a5 100644 --- a/test/e2e/cli-views.test.ts +++ b/test/e2e/cli-views.test.ts @@ -144,8 +144,8 @@ describe('stdin and stdout', () => { expect(cliRun.out).toContain('name="Pay out"'); expect(cliRun.err).toContain('written: -'); const created = ok(['new', '-', '--name', 'Fresh']); - expect((await Doc.fromXml(created.out)).processes().map((processElement) => processElement.get('id'))).toEqual(['Process_Fresh']); - expect(created.err).toContain('created process Process_Fresh "Fresh"'); + expect((await Doc.fromXml(created.out)).processes().map((processElement) => processElement.get('id'))).toEqual(['fresh']); + expect(created.err).toContain('created process fresh "Fresh"'); // a dry run on stdin writes nothing: the report stays on stdout const dry = ok(['set', '-', 'Activity_Pay', 'name=Y', '--dry-run', '--no-layout'], MODEL); expect(dry.out).toContain('changed serviceTask Activity_Pay "Y"'); @@ -193,14 +193,14 @@ describe('guide --short, guide , kinds --section', () => { it('the CAMUNDA 8 recipe of the guide runs', () => { const file = join(dir, 'c8.bpmn'); ok(['new', file, '--name', 'Order', '--target', 'camunda8']); - ok(['add', file, 'start', 'Received', '--id', 'Event_Received']); - ok(['add', file, 'serviceTask', 'Charge', '--after', 'Event_Received', '--id', 'Activity_Charge']); - ok(['add', file, 'callActivity', 'Bill', '--after', 'Activity_Charge', '--id', 'Activity_Bill']); - ok(['add', file, 'userTask', 'Review', '--after', 'Activity_Bill', '--id', 'Activity_Review']); - ok(['add', file, 'businessRuleTask', 'Rate', '--after', 'Activity_Review', '--id', 'Activity_Rate']); - ok(['add', file, 'receiveTask', 'Wait for payment', '--after', 'Activity_Rate', '--id', 'Activity_Wait']); - ok(['add', file, 'sendTask', 'Inform', '--after', 'Activity_Wait', '--id', 'Activity_Inform']); - ok(['add', file, 'end', 'Done', '--after', 'Activity_Inform']); + ok(['add', file, 'start', 'Received', '--id', 'startEvent_received']); + ok(['add', file, 'serviceTask', 'Charge', '--after', 'startEvent_received', '--id', 'serviceTask_charge']); + ok(['add', file, 'callActivity', 'Bill', '--after', 'serviceTask_charge', '--id', 'callActivity_bill']); + ok(['add', file, 'userTask', 'Review', '--after', 'callActivity_bill', '--id', 'userTask_review']); + ok(['add', file, 'businessRuleTask', 'Rate', '--after', 'userTask_review', '--id', 'businessRuleTask_rate']); + ok(['add', file, 'receiveTask', 'Wait for payment', '--after', 'businessRuleTask_rate', '--id', 'receiveTask_waitForPayment']); + ok(['add', file, 'sendTask', 'Inform', '--after', 'receiveTask_waitForPayment', '--id', 'sendTask_inform']); + ok(['add', file, 'end', 'Done', '--after', 'sendTask_inform']); const recipe = guideTopic('camunda8') .split('\n') .map((line) => /^ {4}bpmn ((?:ext add|set) f\.bpmn .*?)(?: {2,}.*)?$/.exec(line)?.[1]) @@ -211,7 +211,7 @@ describe('guide --short, guide , kinds --section', () => { const words = [...command.matchAll(/(?:[^\s']+|'[^']*')+/g)].map((match) => match[0].replace(/'/g, '')); ok(words.map((word) => (word === 'f.bpmn' ? file : word))); } - const around = ok(['show', file, '--around', 'Activity_Charge', '--depth', '0']).out; + const around = ok(['show', file, '--around', 'serviceTask_charge', '--depth', '0']).out; expect(around).toContain('zeebe:taskDefinition type=charge-card retries=3, io: in amount; out receipt, zeebe:taskHeaders: channel'); }, 120000); }); diff --git a/test/e2e/cli.test.ts b/test/e2e/cli.test.ts index ecd37f45..57a7096d 100644 --- a/test/e2e/cli.test.ts +++ b/test/e2e/cli.test.ts @@ -70,7 +70,7 @@ afterAll(() => { describe('bpmn cli session', () => { it('creates a file with one process', async () => { const result = ok('new', file, '--name', 'Order handling', '--target', 'camunda8'); - expect(result.out).toMatch(/Process_OrderHandling/); + expect(result.out).toMatch(/orderHandling/); expect(result.out).toMatch(/written: /); const xml = readFileSync(file, 'utf8'); expect(xml).toContain('xmlns:zeebe'); @@ -81,41 +81,41 @@ describe('bpmn cli session', () => { it('builds a process with the placement grammar', async () => { ok('add', file, 'startEvent', 'Order received'); - ok('add', file, 'userTask', 'Check invoice', '--after', 'Event_OrderReceived'); - ok('add', file, 'xor', 'Invoice ok?', '--after', 'Activity_CheckInvoice'); - let result = ok('add', file, 'serviceTask', 'Book invoice', '--after', 'Gateway_InvoiceOk', '--flow-name', 'yes', '--condition', '${ok}'); - expect(result.out).toMatch(/appended after Gateway_InvoiceOk/); - ok('add', file, 'endEvent', 'Invoice booked', '--after', 'Activity_BookInvoice'); - result = ok('add', file, 'userTask', 'Clarify invoice', '--after', 'Gateway_InvoiceOk', '--flow-name', 'no', '--default', '--to', 'Activity_CheckInvoice'); - expect(result.out).toMatch(/Activity_ClarifyInvoice -> Activity_CheckInvoice/); - result = ok('add', file, 'boundaryEvent:timer', '2 days', '--on', 'Activity_CheckInvoice', '--timer', 'PT2D', '--non-interrupting'); - expect(result.out).toMatch(/attached to Activity_CheckInvoice/); - ok('add', file, 'sendTask', 'Remind clerk', '--after', 'Event_2Days'); + ok('add', file, 'userTask', 'Check invoice', '--after', 'startEvent_orderReceived'); + ok('add', file, 'xor', 'Invoice ok?', '--after', 'userTask_checkInvoice'); + let result = ok('add', file, 'serviceTask', 'Book invoice', '--after', 'gateway_invoiceOk', '--flow-name', 'yes', '--condition', '${ok}'); + expect(result.out).toMatch(/appended after gateway_invoiceOk/); + ok('add', file, 'endEvent', 'Invoice booked', '--after', 'serviceTask_bookInvoice'); + result = ok('add', file, 'userTask', 'Clarify invoice', '--after', 'gateway_invoiceOk', '--flow-name', 'no', '--default', '--to', 'userTask_checkInvoice'); + expect(result.out).toMatch(/userTask_clarifyInvoice -> userTask_checkInvoice/); + result = ok('add', file, 'boundaryEvent:timer', '2 days', '--on', 'userTask_checkInvoice', '--timer', 'PT2D', '--non-interrupting'); + expect(result.out).toMatch(/attached to userTask_checkInvoice/); + ok('add', file, 'sendTask', 'Remind clerk', '--after', 'event_timer2Days'); const doc = await reparse(); - const gw = doc.get('Gateway_InvoiceOk')!; + const gw = doc.get('gateway_invoiceOk')!; expect(doc.outgoing(gw)).toHaveLength(2); expect(gw.get('default').get('name')).toBe('no'); const yes = doc.outgoing(gw).find((flow) => flow.get('name') === 'yes')!; expect(yes.get('conditionExpression').body).toBe('${ok}'); - const be = doc.get('Event_2Days')!; + const be = doc.get('event_timer2Days')!; expect(be.get('cancelActivity')).toBe(false); expect(be.get('eventDefinitions')[0].timeDuration.body).toBe('PT2D'); const xml = readFileSync(file, 'utf8'); - for (const id of ['Event_OrderReceived', 'Activity_CheckInvoice', 'Gateway_InvoiceOk', 'Activity_BookInvoice', 'Event_InvoiceBooked', 'Activity_ClarifyInvoice', 'Event_2Days', 'Activity_RemindClerk']) { + for (const id of ['startEvent_orderReceived', 'userTask_checkInvoice', 'gateway_invoiceOk', 'serviceTask_bookInvoice', 'endEvent_invoiceBooked', 'userTask_clarifyInvoice', 'event_timer2Days', 'sendTask_remindClerk']) { expect(diIds(xml)).toContain(id); } }); it('shows the model without coordinates', () => { const result = ok('show', file); - expect(result.out).toMatch(/process Process_OrderHandling/); - expect(result.out).toMatch(/userTask Activity_CheckInvoice "Check invoice"/); - expect(result.out).toMatch(/exclusiveGateway Gateway_InvoiceOk/); - expect(result.out).toMatch(/boundaryEvent:timer Event_2Days/); + expect(result.out).toMatch(/process orderHandling/); + expect(result.out).toMatch(/userTask userTask_checkInvoice "Check invoice"/); + expect(result.out).toMatch(/exclusiveGateway gateway_invoiceOk/); + expect(result.out).toMatch(/boundaryEvent:timer event_timer2Days/); expect(result.out).not.toMatch(/x=|Bounds|waypoint/); const shown = ok('show', file, '--json').json(); - expect(shown.processes[0].nodes.map((node: any) => node.id)).toContain('Activity_CheckInvoice'); - const gatewayView = ok('show', file, 'Gateway_InvoiceOk', '--json').json(); + expect(shown.processes[0].nodes.map((node: any) => node.id)).toContain('userTask_checkInvoice'); + const gatewayView = ok('show', file, 'gateway_invoiceOk', '--json').json(); expect(gatewayView.kind).toBe('exclusiveGateway'); expect(gatewayView.outgoing).toHaveLength(2); const found = ok('find', file, 'invoice', '--json').json(); @@ -125,49 +125,49 @@ describe('bpmn cli session', () => { it('validates, sets properties and manages extensions', async () => { let result = ok('validate', file); expect(result.out).toMatch(/layout: ok/); - ok('set', file, 'Activity_CheckInvoice', 'name=Check the invoice', 'zeebe:modelerTemplate=check-form', 'doc=Compare with purchase order'); - ok('ext', 'add', file, 'Activity_BookInvoice', 'zeebe:taskDefinition', 'type=book-invoice', 'retries=3'); - result = ok('ext', 'list', file, 'Activity_BookInvoice'); + ok('set', file, 'userTask_checkInvoice', 'name=Check the invoice', 'zeebe:modelerTemplate=check-form', 'doc=Compare with purchase order'); + ok('ext', 'add', file, 'serviceTask_bookInvoice', 'zeebe:taskDefinition', 'type=book-invoice', 'retries=3'); + result = ok('ext', 'list', file, 'serviceTask_bookInvoice'); expect(result.out).toMatch(/zeebe:taskDefinition/); const doc = await reparse(); - const checkTask = doc.get('Activity_CheckInvoice')!; + const checkTask = doc.get('userTask_checkInvoice')!; expect(checkTask.get('name')).toBe('Check the invoice'); expect(checkTask.$attrs['zeebe:modelerTemplate']).toBe('check-form'); expect(checkTask.get('documentation')[0].text).toBe('Compare with purchase order'); const xml = readFileSync(file, 'utf8'); expect(xml).toMatch(/('loopCharacteristics').$type).toBe('bpmn:MultiInstanceLoopCharacteristics'); + expect(doc2.get('serviceTask_bookInvoice')!.get('loopCharacteristics').$type).toBe('bpmn:MultiInstanceLoopCharacteristics'); }); it('retypes, removes with bridging and reorders', async () => { - ok('retype', file, 'Activity_BookInvoice', 'userTask'); + ok('retype', file, 'serviceTask_bookInvoice', 'userTask'); let doc = await reparse(); - expect(doc.get('Activity_BookInvoice')!.$type).toBe('bpmn:UserTask'); - expect(doc.incoming(doc.get('Activity_BookInvoice')!)).toHaveLength(1); - const result = ok('remove', file, 'Activity_BookInvoice'); + expect(doc.get('serviceTask_bookInvoice')!.$type).toBe('bpmn:UserTask'); + expect(doc.incoming(doc.get('serviceTask_bookInvoice')!)).toHaveLength(1); + const result = ok('remove', file, 'serviceTask_bookInvoice'); expect(result.out).toMatch(/bridged/); doc = await reparse(); - expect(doc.get('Activity_BookInvoice')).toBeUndefined(); - const yes = doc.outgoing(doc.get('Gateway_InvoiceOk')!).find((flow) => flow.get('name') === 'yes')!; - expect(yes.get('targetRef').id).toBe('Event_InvoiceBooked'); - const flows = doc.outgoing(doc.get('Gateway_InvoiceOk')!).map((flow) => flow.get('id')); - ok('order', file, 'Gateway_InvoiceOk', flows[1]!, flows[0]!); + expect(doc.get('serviceTask_bookInvoice')).toBeUndefined(); + const yes = doc.outgoing(doc.get('gateway_invoiceOk')!).find((flow) => flow.get('name') === 'yes')!; + expect(yes.get('targetRef').id).toBe('endEvent_invoiceBooked'); + const flows = doc.outgoing(doc.get('gateway_invoiceOk')!).map((flow) => flow.get('id')); + ok('order', file, 'gateway_invoiceOk', flows[1]!, flows[0]!); doc = await reparse(); - expect(doc.outgoing(doc.get('Gateway_InvoiceOk')!).map((flow) => flow.get('id'))).toEqual([flows[1], flows[0]]); + expect(doc.outgoing(doc.get('gateway_invoiceOk')!).map((flow) => flow.get('id'))).toEqual([flows[1], flows[0]]); }); it('applies a JSON batch with a split macro', async () => { const ops = { ops: [ - { op: 'add', kind: 'subProcess', name: 'Payment', after: 'Gateway_InvoiceOk', before: 'Event_InvoiceBooked' }, - { op: 'add', kind: 'startEvent', name: 'Pay start', in: 'Activity_Payment' }, - { op: 'add', kind: 'serviceTask', name: 'Charge card', after: 'Event_PayStart' }, - { op: 'add', kind: 'endEvent', name: 'Paid', after: 'Activity_ChargeCard' }, + { op: 'add', kind: 'subProcess', name: 'Payment', after: 'gateway_invoiceOk', before: 'endEvent_invoiceBooked' }, + { op: 'add', kind: 'startEvent', name: 'Pay start', in: 'subProcess_payment' }, + { op: 'add', kind: 'serviceTask', name: 'Charge card', after: 'startEvent_payStart' }, + { op: 'add', kind: 'endEvent', name: 'Paid', after: 'serviceTask_chargeCard' }, { op: 'split', - after: 'Activity_ChargeCard', + after: 'serviceTask_chargeCard', kind: 'exclusiveGateway', name: 'Charged?', branches: [ @@ -175,11 +175,11 @@ describe('bpmn cli session', () => { { flowName: 'no', default: true, nodes: [{ kind: 'userTask', name: 'Retry manually' }] }, ], }, - { op: 'add', kind: 'dataObject', name: 'Invoice', in: 'Process_OrderHandling' }, - { op: 'connect', source: 'Activity_CheckInvoice', target: 'DataObjectReference_Invoice' }, + { op: 'add', kind: 'dataObject', name: 'Invoice', in: 'orderHandling' }, + { op: 'connect', source: 'userTask_checkInvoice', target: 'dataObject_invoice' }, // an unnamed element gets a hashed id: give it one to refer to it later in the batch - { op: 'add', kind: 'textAnnotation', id: 'TextAnnotation_Fallback', text: 'Manual fallback', in: 'Activity_Payment' }, - { op: 'connect', source: 'TextAnnotation_Fallback', target: 'Activity_RetryManually' }, + { op: 'add', kind: 'textAnnotation', id: 'TextAnnotation_Fallback', text: 'Manual fallback', in: 'subProcess_payment' }, + { op: 'connect', source: 'TextAnnotation_Fallback', target: 'userTask_retryManually' }, ], }; const opsFile = join(dir, 'ops.json'); @@ -187,44 +187,44 @@ describe('bpmn cli session', () => { const result = ok('apply', file, opsFile, '--json'); const applied = result.json(); expect(applied.ok).toBe(true); - expect(applied.created.map((created: any) => created.id)).toEqual(expect.arrayContaining(['Activity_Payment', 'Gateway_Charged', 'Activity_RetryManually', 'DataObjectReference_Invoice'])); + expect(applied.created.map((created: any) => created.id)).toEqual(expect.arrayContaining(['subProcess_payment', 'gateway_charged', 'userTask_retryManually', 'dataObject_invoice'])); const doc = await reparse(); - const sub = doc.get('Activity_Payment')!; + const sub = doc.get('subProcess_payment')!; expect(doc.flowNodes(sub).length).toBeGreaterThanOrEqual(5); const xml = readFileSync(file, 'utf8'); - expect(xml).toMatch(/bpmnElement="Activity_Payment" isExpanded="true"/); - expect(diIds(xml)).toContain('Activity_RetryManually'); - expect(diIds(xml)).toContain('DataObjectReference_Invoice'); + expect(xml).toMatch(/bpmnElement="subProcess_payment" isExpanded="true"/); + expect(diIds(xml)).toContain('userTask_retryManually'); + expect(diIds(xml)).toContain('dataObject_invoice'); expect(diIds(xml)).toContain('TextAnnotation_Fallback'); const report = ok('validate', file, '--json').json(); expect(report.ok).toBe(true); }); it('collapses and re-expands sub-processes through layout', async () => { - ok('layout', file, '--collapse', 'Activity_Payment'); + ok('layout', file, '--collapse', 'subProcess_payment'); let xml = readFileSync(file, 'utf8'); - expect(xml).toMatch(/BPMNPlane_Activity_Payment/); - expect(xml).not.toMatch(/bpmnElement="Activity_Payment" isExpanded="true"/); - ok('add', file, 'task', 'Unrelated', '--in', 'Process_OrderHandling'); + expect(xml).toMatch(/BPMNPlane_subProcess_payment/); + expect(xml).not.toMatch(/bpmnElement="subProcess_payment" isExpanded="true"/); + ok('add', file, 'task', 'Unrelated', '--in', 'orderHandling'); xml = readFileSync(file, 'utf8'); - expect(xml).toMatch(/BPMNPlane_Activity_Payment/); - ok('set', file, 'Activity_Payment', 'expanded=true'); + expect(xml).toMatch(/BPMNPlane_subProcess_payment/); + ok('set', file, 'subProcess_payment', 'expanded=true'); xml = readFileSync(file, 'utf8'); - expect(xml).toMatch(/bpmnElement="Activity_Payment" isExpanded="true"/); + expect(xml).toMatch(/bpmnElement="subProcess_payment" isExpanded="true"/); }); it('adds pools and message flows', async () => { ok('add', file, 'participant', 'Shop'); ok('add', file, 'participant', 'Customer', '--black-box'); - const result = ok('connect', file, 'Activity_CheckInvoice', 'Participant_Customer', '--name', 'Clarification request'); + const result = ok('connect', file, 'userTask_checkInvoice', 'participant_customer', '--name', 'Clarification request'); expect(result.out).toMatch(/messageFlow/); const doc = await reparse(); expect(doc.collaboration()).toBeDefined(); expect(doc.participants()).toHaveLength(2); expect(doc.messageFlows()).toHaveLength(1); const xml = readFileSync(file, 'utf8'); - expect(diIds(xml)).toContain('Participant_Shop'); - expect(diIds(xml)).toContain('Participant_Customer'); + expect(diIds(xml)).toContain('participant_shop'); + expect(diIds(xml)).toContain('participant_customer'); }); it('reports errors with codes and exit codes', () => { @@ -239,10 +239,10 @@ describe('bpmn cli session', () => { result = bpmn('add', file, 'userTask', 'X', '--after', 'Nope', '--json'); expect(result.code).toBe(2); expect(JSON.parse(result.err).error.code).toBe('E_NOT_FOUND'); - result = bpmn('add', file, 'userTask', 'Y', '--after', 'Gateway_InvoiceOk', '--dry-run'); + result = bpmn('add', file, 'userTask', 'Y', '--after', 'gateway_invoiceOk', '--dry-run'); expect(result.code).toBe(0); expect(result.out).toMatch(/dry run/); - result = bpmn('show', file, 'Activity_Y'); + result = bpmn('show', file, 'userTask_y'); expect(result.code).toBe(2); }); @@ -259,22 +259,22 @@ describe('complex gateways (#74)', () => { const modelFile = join(dir, 'complex.bpmn'); ok('new', modelFile, '--id', 'P'); ok('add', modelFile, 'start', 'S'); - expect(ok('add', modelFile, 'complex', 'Enough reviews?', '--after', 'Event_S').out).toMatch(/created complexGateway Gateway_EnoughReviews/); - ok('add', modelFile, 'task', 'Legal', '--after', 'Gateway_EnoughReviews'); - ok('add', modelFile, 'task', 'Finance', '--after', 'Gateway_EnoughReviews'); - ok('add', modelFile, 'parallelGateway', '--after', 'Activity_Legal', '--id', 'Join'); - ok('connect', modelFile, 'Activity_Finance', 'Join'); + expect(ok('add', modelFile, 'complex', 'Enough reviews?', '--after', 'startEvent_s').out).toMatch(/created complexGateway gateway_enoughReviews/); + ok('add', modelFile, 'task', 'Legal', '--after', 'gateway_enoughReviews'); + ok('add', modelFile, 'task', 'Finance', '--after', 'gateway_enoughReviews'); + ok('add', modelFile, 'parallelGateway', '--after', 'task_legal', '--id', 'Join'); + ok('connect', modelFile, 'task_finance', 'Join'); ok('add', modelFile, 'end', 'Done', '--after', 'Join'); ok('retype', modelFile, 'Join', 'complexGateway'); - expect(ok('retype', modelFile, 'Gateway_EnoughReviews', 'inclusiveGateway').out).toMatch(/changed inclusiveGateway Gateway_EnoughReviews/); - ok('retype', modelFile, 'Gateway_EnoughReviews', 'complex'); + expect(ok('retype', modelFile, 'gateway_enoughReviews', 'inclusiveGateway').out).toMatch(/changed inclusiveGateway gateway_enoughReviews/); + ok('retype', modelFile, 'gateway_enoughReviews', 'complex'); expect(ok('validate', modelFile).out).toMatch(/valid, 0 warning/); expect(ok('layout', modelFile).code).toBe(0); expect(ok('render', modelFile, '-o', join(dir, 'complex.svg')).out).toMatch(/wrote/); - expect(bpmn('validate', modelFile, '--platform', 'c7').out).toMatch(/W_C7_DEPLOY_COMPLEX_GATEWAY Gateway_EnoughReviews/); + expect(bpmn('validate', modelFile, '--platform', 'c7').out).toMatch(/W_C7_DEPLOY_COMPLEX_GATEWAY gateway_enoughReviews/); expect(bpmn('validate', modelFile, '--platform', 'c8').out).toMatch(/W_C8_DEPLOY_UNSUPPORTED Join/); expect(bpmn('remove', modelFile, 'Join').err).toMatch(/E_AMBIGUOUS_BRIDGE: Join is a complex join/); - expect(ok('remove', modelFile, 'Join', '--bridge-all').out).toMatch(/note: Join synchronised Activity_Legal, Activity_Finance/); + expect(ok('remove', modelFile, 'Join', '--bridge-all').out).toMatch(/note: Join synchronised task_legal, task_finance/); }); }); @@ -289,8 +289,8 @@ describe('bpmn cli contracts', () => { modelFile = join(dir, 'contracts.bpmn'); ok('new', modelFile, '--id', 'P'); ok('add', modelFile, 'start', 'S'); - ok('add', modelFile, 'subProcess', 'Sub', '--after', 'Event_S'); - ok('add', modelFile, 'start', 'Inner', '--in', 'Activity_Sub'); + ok('add', modelFile, 'subProcess', 'Sub', '--after', 'startEvent_s'); + ok('add', modelFile, 'start', 'Inner', '--in', 'subProcess_sub'); }); it('reports a missing ops file as an I/O error', () => { @@ -328,41 +328,41 @@ describe('bpmn cli contracts', () => { }); it('validates and reports layout --expand / --collapse', () => { - let result = ok('layout', modelFile, '--expand', 'Activity_Sub'); - expect(result.out).toMatch(/changed subProcess Activity_Sub "Sub" - expanded=true/); - result = bpmn('layout', modelFile, '--collapse', 'Event_S'); + let result = ok('layout', modelFile, '--expand', 'subProcess_sub'); + expect(result.out).toMatch(/changed subProcess subProcess_sub "Sub" - expanded=true/); + result = bpmn('layout', modelFile, '--collapse', 'startEvent_s'); expect(result.code).toBe(2); expect(result.err).toMatch(/error E_WRONG_KIND/); - result = bpmn('layout', modelFile, '--expand', 'Activity_Su', '--json'); + result = bpmn('layout', modelFile, '--expand', 'subProcess_su', '--json'); expect(result.code).toBe(2); const cliError = JSON.parse(result.err).error; expect(cliError.code).toBe('E_NOT_FOUND'); - expect(cliError.candidates).toEqual(['Activity_Sub']); + expect(cliError.candidates).toEqual(['subProcess_sub']); expect(bpmn('layout', modelFile, '--no-layout').code).toBe(1); - expect(bpmn('layout', modelFile, '--expand', 'Activity_Sub', '--collapse', 'Activity_Sub').code).toBe(1); - result = ok('layout', modelFile, '--collapse', 'Activity_Sub', '--json'); - expect(result.json().changed).toEqual([{ id: 'Activity_Sub', kind: 'subProcess', name: 'Sub', detail: 'expanded=false' }]); + expect(bpmn('layout', modelFile, '--expand', 'subProcess_sub', '--collapse', 'subProcess_sub').code).toBe(1); + result = ok('layout', modelFile, '--collapse', 'subProcess_sub', '--json'); + expect(result.json().changed).toEqual([{ id: 'subProcess_sub', kind: 'subProcess', name: 'Sub', detail: 'expanded=false' }]); expect(result.json().layout.expanded).toEqual([]); - ok('layout', modelFile, '--expand', 'Activity_Sub'); + ok('layout', modelFile, '--expand', 'subProcess_sub'); }); it('keeps collapse requests made with --no-layout', () => { - let result = ok('set', modelFile, 'Activity_Sub', 'expanded=false', '--no-layout'); + let result = ok('set', modelFile, 'subProcess_sub', 'expanded=false', '--no-layout'); expect(result.out).toMatch(/expanded=false/); - expect(readFileSync(modelFile, 'utf8')).toMatch(/bpmnElement="Activity_Sub" isExpanded="false"/); - expect(ok('show', modelFile, 'Activity_Sub').out).toMatch(/expanded: false/); + expect(readFileSync(modelFile, 'utf8')).toMatch(/bpmnElement="subProcess_sub" isExpanded="false"/); + expect(ok('show', modelFile, 'subProcess_sub').out).toMatch(/expanded: false/); ok('layout', modelFile); let xml = readFileSync(modelFile, 'utf8'); - expect(xml).toMatch(/BPMNPlane_Activity_Sub/); - expect(xml).not.toMatch(/bpmnElement="Activity_Sub" isExpanded="true"/); - result = ok('add', modelFile, 'subProcess', 'Other', '--after', 'Activity_Sub', '--collapsed', '--no-layout'); + expect(xml).toMatch(/BPMNPlane_subProcess_sub/); + expect(xml).not.toMatch(/bpmnElement="subProcess_sub" isExpanded="true"/); + result = ok('add', modelFile, 'subProcess', 'Other', '--after', 'subProcess_sub', '--collapsed', '--no-layout'); expect(result.out).toMatch(/laid out collapsed/); ok('layout', modelFile); xml = readFileSync(modelFile, 'utf8'); - expect(xml).toMatch(/BPMNPlane_Activity_Other/); - expect(xml).not.toMatch(/bpmnElement="Activity_Other" isExpanded="true"/); - ok('layout', modelFile, '--expand', 'Activity_Sub,Activity_Other'); - expect(readFileSync(modelFile, 'utf8')).toMatch(/bpmnElement="Activity_Other" isExpanded="true"/); + expect(xml).toMatch(/BPMNPlane_subProcess_other/); + expect(xml).not.toMatch(/bpmnElement="subProcess_other" isExpanded="true"/); + ok('layout', modelFile, '--expand', 'subProcess_sub,subProcess_other'); + expect(readFileSync(modelFile, 'utf8')).toMatch(/bpmnElement="subProcess_other" isExpanded="true"/); }); it('re-attaches boundary events with move --on', async () => { @@ -370,13 +370,13 @@ describe('bpmn cli contracts', () => { ok('new', moveFile, '--id', 'M'); ok('add', moveFile, 'task', 'A', '--in', 'M'); ok('add', moveFile, 'task', 'B', '--in', 'M'); - ok('add', moveFile, 'boundary:timer', 'T', '--on', 'Activity_A', '--timer', 'PT1H'); - const result = ok('move', moveFile, 'Event_T', '--on', 'Activity_B'); - expect(result.out).toMatch(/moved onto Activity_B \(was Activity_A\)/); + ok('add', moveFile, 'boundary:timer', 'T', '--on', 'task_a', '--timer', 'PT1H'); + const result = ok('move', moveFile, 'event_t', '--on', 'task_b'); + expect(result.out).toMatch(/moved onto task_b \(was task_a\)/); const doc = await Doc.fromXml(readFileSync(moveFile, 'utf8')); - expect(doc.get('Event_T')!.get('attachedToRef').id).toBe('Activity_B'); - expect(bpmn('move', moveFile, 'Event_T').err).toMatch(/--on/); - expect(bpmn('move', moveFile, 'Event_T', '--on', 'Activity_A', '--in', 'M').err).toMatch(/E_USAGE: --in and --on cannot be combined/); + expect(doc.get('event_t')!.get('attachedToRef').id).toBe('task_b'); + expect(bpmn('move', moveFile, 'event_t').err).toMatch(/--on/); + expect(bpmn('move', moveFile, 'event_t', '--on', 'task_a', '--in', 'M').err).toMatch(/E_USAGE: --in and --on cannot be combined/); }); it('applies the ops JSON rules to CLI flags', () => { @@ -387,24 +387,24 @@ describe('bpmn cli contracts', () => { result = bpmn('add', modelFile, 'task', 'Dup', '--if-absent'); expect(result.code).toBe(1); expect(result.err).toMatch(/--if-absent needs an explicit --id/); - result = bpmn('add', modelFile, 'task', 'X', '--after', 'Event_S', '--condition', '${c}', '--default'); + result = bpmn('add', modelFile, 'task', 'X', '--after', 'startEvent_s', '--condition', '${c}', '--default'); expect(result.code).toBe(1); expect(result.err).toMatch(/--default and --condition are mutually exclusive/); - result = bpmn('connect', modelFile, 'Event_S', 'Activity_Sub', '--condition', '${c}', '--default'); + result = bpmn('connect', modelFile, 'startEvent_s', 'subProcess_sub', '--condition', '${c}', '--default'); expect(result.code).toBe(1); - result = bpmn('add', modelFile, 'task', 'X', '--after', 'Event_S', '--in', 'P'); + result = bpmn('add', modelFile, 'task', 'X', '--after', 'startEvent_s', '--in', 'P'); expect(result.code).toBe(1); expect(result.err).toMatch(/--after and --in cannot be combined/); expect(readFileSync(modelFile, 'utf8')).toBe(before); }); it('does not expose diagram interchange ids', () => { - let result = bpmn('show', modelFile, 'BPMNShape_Event_S'); + let result = bpmn('show', modelFile, 'BPMNShape_startEvent_s'); expect(result.code).toBe(2); expect(result.err).toMatch(/error E_NOT_FOUND/); expect(result.err).not.toMatch(/candidates:.*BPMNShape/); - result = bpmn('show', modelFile, 'Event_'); - expect(result.err).toMatch(/candidates: Event_Inner, Event_S$/m); + result = bpmn('show', modelFile, 'startEvent_'); + expect(result.err).toMatch(/candidates: startEvent_inner, startEvent_s$/m); expect(bpmn('remove', modelFile, 'BPMNPlane_P').code).toBe(2); }); @@ -412,25 +412,25 @@ describe('bpmn cli contracts', () => { const syncFile = join(dir, 'sync.bpmn'); ok('new', syncFile, '--name', 'Sync'); ok('add', syncFile, 'start', 'Every minute'); - ok('add', syncFile, 'serviceTask', 'Consignations synchronisieren', '--after', 'Event_EveryMinute'); - ok('add', syncFile, 'end', 'Consignations aktualisiert', '--after', 'Activity_ConsignationsSynchronisieren'); - const result = ok('add', syncFile, 'serviceTask', 'Consignations protokollieren', '--after', 'Activity_ConsignationsSynchronisieren'); + ok('add', syncFile, 'serviceTask', 'Consignations synchronisieren', '--after', 'startEvent_everyMinute'); + ok('add', syncFile, 'end', 'Consignations aktualisiert', '--after', 'serviceTask_consignationsSynchronisieren'); + const result = ok('add', syncFile, 'serviceTask', 'Consignations protokollieren', '--after', 'serviceTask_consignationsSynchronisieren'); expect(result.out).toMatch( - /^changed sequenceFlow Flow_ConsignationsSynchronisierenToConsignationsProtokollieren - .* \(renamed from Flow_ConsignationsSynchronisierenToConsignationsAktualisiert: its id named its old ends\)$/m, + /^changed sequenceFlow flow_consignationsSynchronisierenToConsignationsProtokollieren - .* \(renamed from flow_consignationsSynchronisierenToConsignationsAktualisiert: its id named its old ends\)$/m, ); }); it('does not write a result that equals the file, and says so', () => { const before = readFileSync(modelFile, 'utf8'); - let result = ok('set', modelFile, 'Event_S', 'name=S'); + let result = ok('set', modelFile, 'startEvent_s', 'name=S'); expect(result.out).toMatch(/^unchanged: .*contracts\.bpmn \(the result equals the file; nothing written\)$/m); expect(result.out).not.toMatch(/^written: /m); - result = ok('set', modelFile, 'Event_S', 'name=S', '--json'); + result = ok('set', modelFile, 'startEvent_s', 'name=S', '--json'); expect(result.json()).toMatchObject({ ok: true, written: false, unchanged: true }); - result = ok('set', modelFile, 'Event_S', 'name=S', '--dry-run'); + result = ok('set', modelFile, 'startEvent_s', 'name=S', '--dry-run'); expect(result.out).toMatch(/^dry run: .* not written \(unchanged\)$/m); expect(readFileSync(modelFile, 'utf8')).toBe(before); - result = ok('set', modelFile, 'Event_S', 'name=Started', '--json'); + result = ok('set', modelFile, 'startEvent_s', 'name=Started', '--json'); expect(result.json()).toMatchObject({ written: true, unchanged: false }); }); @@ -442,15 +442,15 @@ describe('bpmn cli contracts', () => { expect(JSON.parse(result.stdout)).toMatchObject({ ok: true, file: '-' }); result = piped('(head -c 200 "$1"; sleep 0.5; tail -c +201 "$1") | "$2" "$3" show -'); expect(result.status, result.stderr).toBe(0); - expect(result.stdout).toContain('Activity_Sub'); - result = piped('(sleep 0.5; echo \'[{"op":"set","id":"Event_S","values":{"name":"Started late"}}]\') | "$2" "$3" apply "$1" - --dry-run --json'); + expect(result.stdout).toContain('subProcess_sub'); + result = piped('(sleep 0.5; echo \'[{"op":"set","id":"startEvent_s","values":{"name":"Started late"}}]\') | "$2" "$3" apply "$1" - --dry-run --json'); expect(result.status, result.stderr).toBe(0); }); it('a write to a directory (-o) is E_IO and leaves no temp file', () => { const target = join(dir, 'adir.bpmn'); mkdirSync(target); - const result = bpmn('add', modelFile, 'task', 'X', '--after', 'Event_S', '-o', target); + const result = bpmn('add', modelFile, 'task', 'X', '--after', 'startEvent_s', '-o', target); expect(result.code).toBe(4); expect(result.err).toMatch(/^error E_IO: Cannot write .*adir\.bpmn: it is a directory\n/); expect(readdirSync(dir).filter((fileName) => fileName.endsWith('.tmp'))).toEqual([]); @@ -459,8 +459,8 @@ describe('bpmn cli contracts', () => { describe('flow ids kept (--keep-flow-ids, BPMN_KEEP_FLOW_IDS)', () => { let base: string; - const splice = ['serviceTask', 'Book invoice', '--after', 'Activity_CheckInvoice']; - const STALE = 'warning W_FLOW_ID_STALE Flow_CheckInvoiceToInvoiceDone: Flow Flow_CheckInvoiceToInvoiceDone kept its id, which names its old ends Activity_CheckInvoice -> Event_InvoiceDone; it now runs Activity_CheckInvoice -> Activity_BookInvoice (Code and tests that use the id keep working; to rename it together with them: `bpmn set Flow_CheckInvoiceToInvoiceDone id=Flow_CheckInvoiceToBookInvoice`.)'; + const splice = ['serviceTask', 'Book invoice', '--after', 'userTask_checkInvoice']; + const STALE = 'warning W_FLOW_ID_STALE flow_checkInvoiceToInvoiceDone: Flow flow_checkInvoiceToInvoiceDone kept its id, which names its old ends userTask_checkInvoice -> endEvent_invoiceDone; it now runs userTask_checkInvoice -> serviceTask_bookInvoice (Code and tests that use the id keep working; to rename it together with them: `bpmn set flow_checkInvoiceToInvoiceDone id=flow_checkInvoiceToBookInvoice`.)'; /** A copy of start -> Check invoice -> end. */ function fresh(name: string): string { @@ -473,8 +473,8 @@ describe('flow ids kept (--keep-flow-ids, BPMN_KEEP_FLOW_IDS)', () => { base = join(dir, 'invoice.bpmn'); ok('new', base, '--name', 'Invoice'); ok('add', base, 'startEvent', 'Invoice received'); - ok('add', base, 'userTask', 'Check invoice', '--after', 'Event_InvoiceReceived'); - ok('add', base, 'endEvent', 'Invoice done', '--after', 'Activity_CheckInvoice'); + ok('add', base, 'userTask', 'Check invoice', '--after', 'startEvent_invoiceReceived'); + ok('add', base, 'endEvent', 'Invoice done', '--after', 'userTask_checkInvoice'); }); it('--keep-flow-ids keeps the id and names the stale flow with the command that renames it; the command works', () => { @@ -482,23 +482,23 @@ describe('flow ids kept (--keep-flow-ids, BPMN_KEEP_FLOW_IDS)', () => { const result = ok('add', modelFile, ...splice, '--keep-flow-ids'); expect(result.out).toContain(`${STALE}\n`); expect(result.out).not.toMatch(/renamed/); - expect(readFileSync(modelFile, 'utf8')).toContain(''); + expect(readFileSync(modelFile, 'utf8')).toContain(''); const command = /`bpmn set (\S+) (id=\S+)`/.exec(result.out)!; - expect(ok('set', modelFile, command[1]!, command[2]!).out).toMatch(/^changed sequenceFlow Flow_CheckInvoiceToBookInvoice /m); + expect(ok('set', modelFile, command[1]!, command[2]!).out).toMatch(/^changed sequenceFlow flow_checkInvoiceToBookInvoice /m); }); it('BPMN_KEEP_FLOW_IDS=1 makes it the default; --no-keep-flow-ids, 0 and an empty value rename', () => { let result = okWith({ BPMN_KEEP_FLOW_IDS: '1' }, 'add', fresh('env-on.bpmn'), ...splice, '--summary'); - expect(result.out).toMatch(/^warning W_FLOW_ID_STALE Flow_CheckInvoiceToInvoiceDone: /m); + expect(result.out).toMatch(/^warning W_FLOW_ID_STALE flow_checkInvoiceToInvoiceDone: /m); expect(result.out).not.toMatch(/^renamed:/m); for (const [env, flag] of [['1', '--no-keep-flow-ids'], ['0', undefined], ['Off', undefined], ['', undefined]] as const) { result = okWith({ BPMN_KEEP_FLOW_IDS: env }, 'add', fresh('env-off.bpmn'), ...splice, '--summary', ...(flag ? [flag] : [])); - expect(result.out, `${env} ${flag ?? ''}`).toMatch(/^renamed: Flow_CheckInvoiceToInvoiceDone -> Flow_CheckInvoiceToBookInvoice$/m); + expect(result.out, `${env} ${flag ?? ''}`).toMatch(/^renamed: flow_checkInvoiceToInvoiceDone -> flow_checkInvoiceToBookInvoice$/m); expect(result.out).not.toMatch(/W_FLOW_ID_STALE/); } result = okWith({ BPMN_KEEP_FLOW_IDS: 'yes' }, 'add', fresh('env-yes.bpmn'), ...splice, '--json'); expect(result.json()).not.toHaveProperty('renamed'); - expect(result.json().warnings.added).toEqual([expect.objectContaining({ code: 'W_FLOW_ID_STALE', element: 'Flow_CheckInvoiceToInvoiceDone', related: ['Activity_CheckInvoice', 'Activity_BookInvoice'] })]); + expect(result.json().warnings.added).toEqual([expect.objectContaining({ code: 'W_FLOW_ID_STALE', element: 'flow_checkInvoiceToInvoiceDone', related: ['userTask_checkInvoice', 'serviceTask_bookInvoice'] })]); }); /** A copy of start -> Check invoice -> Book invoice -> end whose flows have ids that do not name their ends (`ids`: three). */ @@ -506,7 +506,7 @@ describe('flow ids kept (--keep-flow-ids, BPMN_KEEP_FLOW_IDS)', () => { const modelFile = fresh(name); ok('add', modelFile, ...splice); let xml = readFileSync(modelFile, 'utf8'); - ['Flow_InvoiceReceivedToCheckInvoice', 'Flow_CheckInvoiceToBookInvoice', 'Flow_BookInvoiceToInvoiceDone'].forEach((id, flowIndex) => (xml = xml.replaceAll(id, ids[flowIndex]!))); + ['flow_invoiceReceivedToCheckInvoice', 'flow_checkInvoiceToBookInvoice', 'flow_bookInvoiceToInvoiceDone'].forEach((id, flowIndex) => (xml = xml.replaceAll(id, ids[flowIndex]!))); writeFileSync(modelFile, xml); return modelFile; } @@ -515,25 +515,25 @@ describe('flow ids kept (--keep-flow-ids, BPMN_KEEP_FLOW_IDS)', () => { const note = (value: string) => `flow ids kept: BPMN_KEEP_FLOW_IDS=${value} (--no-keep-flow-ids renames flows after their new ends for one command)`; // a stale flow id: after the change's own notes, before the warnings let result = okWith({ BPMN_KEEP_FLOW_IDS: '1' }, 'add', fresh('note-text.bpmn'), ...splice); - expect(result.out).toContain(`\nnote: inserted between Activity_CheckInvoice and Event_InvoiceDone\nnote: ${note('1')}\n${STALE}\n`); + expect(result.out).toContain(`\nnote: inserted between userTask_checkInvoice and endEvent_invoiceDone\nnote: ${note('1')}\n${STALE}\n`); // --summary prints no notes of its own: this one follows the change lines result = okWith({ BPMN_KEEP_FLOW_IDS: '1' }, 'add', fresh('note-summary.bpmn'), ...splice, '--summary'); - expect(result.out).toContain(`\nchanged: Flow_CheckInvoiceToInvoiceDone\nnote: ${note('1')}\nwarning W_FLOW_ID_STALE Flow_CheckInvoiceToInvoiceDone: `); + expect(result.out).toContain(`\nchanged: flow_checkInvoiceToInvoiceDone\nnote: ${note('1')}\nwarning W_FLOW_ID_STALE flow_checkInvoiceToInvoiceDone: `); result = okWith({ BPMN_KEEP_FLOW_IDS: 'yes' }, 'add', fresh('note-json.bpmn'), ...splice, '--json'); - expect(result.json().notes).toEqual(['inserted between Activity_CheckInvoice and Event_InvoiceDone', note('yes')]); + expect(result.json().notes).toEqual(['inserted between userTask_checkInvoice and endEvent_invoiceDone', note('yes')]); const ops = join(dir, 'note-ops.json'); - writeFileSync(ops, JSON.stringify([{ op: 'add', kind: 'serviceTask', name: 'Book invoice', after: 'Activity_CheckInvoice' }])); + writeFileSync(ops, JSON.stringify([{ op: 'add', kind: 'serviceTask', name: 'Book invoice', after: 'userTask_checkInvoice' }])); result = okWith({ BPMN_KEEP_FLOW_IDS: 'On' }, 'apply', fresh('note-summary-json.bpmn'), ops, '--summary', '--json'); expect(result.json().notes).toEqual([note('On')]); // a move puts the moved node's out-flow back without any warning (numbered flow ids): only the note says why const numbered = unnamedFlows('note-numbered.bpmn', ['Flow_1', 'Flow_2', 'Flow_3']); - const move = ['move', numbered, 'Activity_CheckInvoice', '--after', 'Activity_BookInvoice', '--summary', '--dry-run']; + const move = ['move', numbered, 'userTask_checkInvoice', '--after', 'serviceTask_bookInvoice', '--summary', '--dry-run']; result = okWith({ BPMN_KEEP_FLOW_IDS: '1' }, ...move); - expect(result.out).toMatch(/^changed: Flow_1, Flow_3, Flow_2, Activity_CheckInvoice\nnote: flow ids kept: BPMN_KEEP_FLOW_IDS=1 /m); + expect(result.out).toMatch(/^changed: Flow_1, Flow_3, Flow_2, userTask_checkInvoice\nnote: flow ids kept: BPMN_KEEP_FLOW_IDS=1 /m); expect(result.out).not.toMatch(/created sequenceFlow|warning/); - expect(ok(...move).out).toMatch(/^created sequenceFlow: Flow_CheckInvoiceToInvoiceDone\n[\s\S]*^removed: Flow_2$/m); + expect(ok(...move).out).toMatch(/^created sequenceFlow: flow_checkInvoiceToInvoiceDone\n[\s\S]*^removed: Flow_2$/m); // the same move in a batch - writeFileSync(ops, JSON.stringify([{ op: 'move', ids: ['Activity_CheckInvoice'], after: 'Activity_BookInvoice' }])); + writeFileSync(ops, JSON.stringify([{ op: 'move', ids: ['userTask_checkInvoice'], after: 'serviceTask_bookInvoice' }])); expect(okWith({ BPMN_KEEP_FLOW_IDS: '1' }, 'apply', numbered, ops, '--json', '--dry-run').json().notes).toContain(note('1')); }); @@ -543,12 +543,12 @@ describe('flow ids kept (--keep-flow-ids, BPMN_KEEP_FLOW_IDS)', () => { // the ids Camunda Modeler gives const modeler = unnamedFlows('note-none-modeler.bpmn', ['Flow_0k3x9ab', 'Flow_1q7m2zd', 'Flow_0w5n1rt']); const ops = join(dir, 'note-none-ops.json'); - writeFileSync(ops, JSON.stringify([{ op: 'connect', source: 'Event_InvoiceReceived', target: 'Event_InvoiceDone', as: '$f' }, { op: 'set', id: '$f', values: { name: 'skip' } }])); + writeFileSync(ops, JSON.stringify([{ op: 'connect', source: 'startEvent_invoiceReceived', target: 'endEvent_invoiceDone', as: '$f' }, { op: 'set', id: '$f', values: { name: 'skip' } }])); // a move in place relocates nothing: its flow options change the flow into its node the same in both modes const inPlace = join(dir, 'note-none-in-place.json'); const inPlaceNumbered = join(dir, 'note-none-in-place-numbered.json'); - writeFileSync(inPlace, JSON.stringify([{ op: 'move', ids: ['Activity_CheckInvoice'], after: 'Event_InvoiceReceived', flowName: 'go', flowId: 'Flow_ReceivedToCheck' }])); - writeFileSync(inPlaceNumbered, JSON.stringify([{ op: 'move', ids: ['Activity_BookInvoice'], after: 'Activity_CheckInvoice', flowName: 'ok' }])); + writeFileSync(inPlace, JSON.stringify([{ op: 'move', ids: ['userTask_checkInvoice'], after: 'startEvent_invoiceReceived', flowName: 'go', flowId: 'Flow_ReceivedToCheck' }])); + writeFileSync(inPlaceNumbered, JSON.stringify([{ op: 'move', ids: ['serviceTask_bookInvoice'], after: 'userTask_checkInvoice', flowName: 'ok' }])); expect(ok('apply', modelFile, inPlace, '--dry-run', '--summary').out).toMatch(/^changed: Flow_ReceivedToCheck$/m); expect(ok('apply', numbered, inPlaceNumbered, '--dry-run', '--summary').out).toMatch(/^changed: Flow_2$/m); const cases: string[][] = [ @@ -556,19 +556,19 @@ describe('flow ids kept (--keep-flow-ids, BPMN_KEEP_FLOW_IDS)', () => { ['apply', numbered, inPlaceNumbered, '--json'], ['add', modelFile, ...splice, '--keep-flow-ids'], ['add', modelFile, ...splice, '--no-keep-flow-ids', '--summary'], - ['set', modelFile, 'Activity_CheckInvoice', 'name=Check bill'], + ['set', modelFile, 'userTask_checkInvoice', 'name=Check bill'], // a flow the change creates or removes is the same in both modes, also one a batch created and then changed - ['connect', modelFile, 'Event_InvoiceReceived', 'Event_InvoiceDone', '--summary'], - ['remove', modelFile, 'Flow_CheckInvoiceToInvoiceDone', '--json'], + ['connect', modelFile, 'startEvent_invoiceReceived', 'endEvent_invoiceDone', '--summary'], + ['remove', modelFile, 'flow_checkInvoiceToInvoiceDone', '--json'], ['apply', modelFile, ops, '--summary'], // a property of a flow, also the rename W_FLOW_ID_STALE's hint gives - ['set', modelFile, 'Flow_CheckInvoiceToInvoiceDone', 'name=done', '--summary'], - ['set', modelFile, 'Flow_CheckInvoiceToInvoiceDone', 'id=Flow_CheckInvoiceToBookInvoice', '--summary'], + ['set', modelFile, 'flow_checkInvoiceToInvoiceDone', 'name=done', '--summary'], + ['set', modelFile, 'flow_checkInvoiceToInvoiceDone', 'id=flow_checkInvoiceToBookInvoice', '--summary'], // a flow whose id does not name its ends is renamed in neither mode: a splice, a bridging remove, set target= ...[numbered, modeler].flatMap((sample) => [ - ['add', sample, 'userTask', 'Approve invoice', '--after', 'Activity_CheckInvoice', '--summary'], - ['remove', sample, 'Activity_BookInvoice', '--json'], - ['set', sample, sample === numbered ? 'Flow_1' : 'Flow_0k3x9ab', 'target=Activity_BookInvoice'], + ['add', sample, 'userTask', 'Approve invoice', '--after', 'userTask_checkInvoice', '--summary'], + ['remove', sample, 'serviceTask_bookInvoice', '--json'], + ['set', sample, sample === numbered ? 'Flow_1' : 'Flow_0k3x9ab', 'target=serviceTask_bookInvoice'], ]), ]; for (const args of cases) { @@ -583,13 +583,13 @@ describe('flow ids kept (--keep-flow-ids, BPMN_KEEP_FLOW_IDS)', () => { it('an E_NOT_FOUND of a batch says that BPMN_KEEP_FLOW_IDS kept the flow ids: the id the default renames to does not exist', () => { const ops = join(dir, 'note-error-ops.json'); - writeFileSync(ops, JSON.stringify([{ op: 'add', kind: 'serviceTask', name: 'Book invoice', after: 'Activity_CheckInvoice' }, { op: 'set', id: 'Flow_CheckInvoiceToBookInvoice', values: { name: 'ok' } }])); + writeFileSync(ops, JSON.stringify([{ op: 'add', kind: 'serviceTask', name: 'Book invoice', after: 'userTask_checkInvoice' }, { op: 'set', id: 'flow_checkInvoiceToBookInvoice', values: { name: 'ok' } }])); const modelFile = fresh('note-error.bpmn'); - expect(ok('apply', modelFile, ops, '--dry-run', '--summary').out).toMatch(/^renamed: Flow_CheckInvoiceToInvoiceDone -> Flow_CheckInvoiceToBookInvoice$/m); + expect(ok('apply', modelFile, ops, '--dry-run', '--summary').out).toMatch(/^renamed: flow_checkInvoiceToInvoiceDone -> flow_checkInvoiceToBookInvoice$/m); const kept = 'If it is the id a flow an earlier op gave other ends would be renamed to: flow ids are kept here (BPMN_KEEP_FLOW_IDS=1), so that flow kept its old id; use the old id, or --no-keep-flow-ids to rename it after its new ends.'; let result = bpmnWith({ BPMN_KEEP_FLOW_IDS: '1' }, 'apply', modelFile, ops, '--dry-run'); expect(result.code).toBe(2); - expect(result.err).toMatch(/^error E_NOT_FOUND: No element with id "Flow_CheckInvoiceToBookInvoice"\n/); + expect(result.err).toMatch(/^error E_NOT_FOUND: No element with id "flow_checkInvoiceToBookInvoice" \(did you mean: invoice\?\)\n/); expect(result.err).toContain(` to list ids. ${kept}\n`); result = bpmnWith({ BPMN_KEEP_FLOW_IDS: '1' }, 'apply', modelFile, ops, '--dry-run', '--json'); expect(result.json().error.hint.endsWith(` to list ids. ${kept}`)).toBe(true); @@ -603,12 +603,12 @@ describe('flow ids kept (--keep-flow-ids, BPMN_KEEP_FLOW_IDS)', () => { it('apply, remove and set target= keep ids too; --strict exits 5 on the warning', () => { const ops = join(dir, 'keep-ops.json'); - writeFileSync(ops, JSON.stringify([{ op: 'add', kind: 'serviceTask', name: 'Book invoice', after: 'Activity_CheckInvoice' }])); + writeFileSync(ops, JSON.stringify([{ op: 'add', kind: 'serviceTask', name: 'Book invoice', after: 'userTask_checkInvoice' }])); expect(ok('apply', fresh('keep-apply.bpmn'), ops, '--keep-flow-ids').out).toContain(STALE); - const bridged = ok('remove', fresh('keep-remove.bpmn'), 'Activity_CheckInvoice', '--keep-flow-ids').out; - expect(bridged).toMatch(/^warning W_FLOW_ID_STALE Flow_InvoiceReceivedToCheckInvoice: .*`bpmn set Flow_InvoiceReceivedToCheckInvoice id=Flow_InvoiceReceivedToInvoiceDone`/m); - const retargeted = ok('set', fresh('keep-set.bpmn'), 'Flow_InvoiceReceivedToCheckInvoice', 'target=Event_InvoiceDone', '--keep-flow-ids').out; - expect(retargeted).toMatch(/^warning W_FLOW_ID_STALE Flow_InvoiceReceivedToCheckInvoice: /m); + const bridged = ok('remove', fresh('keep-remove.bpmn'), 'userTask_checkInvoice', '--keep-flow-ids').out; + expect(bridged).toMatch(/^warning W_FLOW_ID_STALE flow_invoiceReceivedToCheckInvoice: .*`bpmn set flow_invoiceReceivedToCheckInvoice id=flow_invoiceReceivedToInvoiceDone`/m); + const retargeted = ok('set', fresh('keep-set.bpmn'), 'flow_invoiceReceivedToCheckInvoice', 'target=endEvent_invoiceDone', '--keep-flow-ids').out; + expect(retargeted).toMatch(/^warning W_FLOW_ID_STALE flow_invoiceReceivedToCheckInvoice: /m); const strict = bpmn('add', fresh('keep-strict.bpmn'), ...splice, '--keep-flow-ids', '--strict'); expect(strict.code).toBe(5); expect(strict.out).toContain(STALE); diff --git a/test/e2e/isomorphic.test.ts b/test/e2e/isomorphic.test.ts index 17e15e2f..643d7b28 100644 --- a/test/e2e/isomorphic.test.ts +++ b/test/e2e/isomorphic.test.ts @@ -95,7 +95,7 @@ describe('the browser bundle in a context without Node globals', () => { const there = await browser.applyToXml(xml, JSON.stringify(ops), { debug: (line) => lines.push(line) }); const here = await core.applyToXml(xml, ops); expect(there.result.layout.mode).toBe('incremental'); - expect(there.result.created.map((change) => change.id)).toContain('Task_ShipGoods'); // the file's id style (Task_) + expect(there.result.created.map((change) => change.id)).toContain('userTask_shipGoods'); expect(there.unchanged).toBe(false); expect(there.xml).toBe(here.xml); expect(JSON.stringify(there.result)).toBe(JSON.stringify(here.result)); @@ -106,13 +106,13 @@ describe('the browser bundle in a context without Node globals', () => { it('keeps flow ids (keepFlowIds) and reports the stale one like Node', async () => { const ops = [ { op: 'add', kind: 'start', name: 'Received' }, - { op: 'add', kind: 'task', name: 'Check', after: 'Event_Received' }, - { op: 'add', kind: 'end', name: 'Done', after: 'Activity_Check' }, + { op: 'add', kind: 'task', name: 'Check', after: 'startEvent_received' }, + { op: 'add', kind: 'end', name: 'Done', after: 'task_check' }, ]; const xml = (await core.applyToXml((await core.newXml({ processName: 'Order' })).xml, ops)).xml; - const splice = [{ op: 'add', kind: 'task', name: 'Book', after: 'Activity_Check' }]; + const splice = [{ op: 'add', kind: 'task', name: 'Book', after: 'task_check' }]; const there = await browser.applyToXml(xml, splice, { keepFlowIds: true }); - expect(there.result.warnings.added.map((warning) => `${warning.code} ${warning.element}`)).toEqual(['W_FLOW_ID_STALE Flow_CheckToDone']); + expect(there.result.warnings.added.map((warning) => `${warning.code} ${warning.element}`)).toEqual(['W_FLOW_ID_STALE flow_checkToDone']); expect(JSON.stringify(there.result)).toBe(JSON.stringify((await core.applyToXml(xml, splice, { keepFlowIds: true })).result)); }); @@ -130,21 +130,21 @@ describe('the browser bundle in a context without Node globals', () => { const created = await browser.newXml({ processName: 'Order', target: 'camunda7' }); const edited = await browser.applyToXml(created.xml, [ { op: 'add', kind: 'start', name: 'Received' }, - { op: 'add', kind: 'userTask', name: 'Check', after: 'Event_Received', set: { 'camunda:assignee': 'demo' } }, + { op: 'add', kind: 'userTask', name: 'Check', after: 'startEvent_received', set: { 'camunda:assignee': 'demo' } }, ]); expect(edited.xml).toBe((await core.applyToXml((await core.newXml({ processName: 'Order', target: 'camunda7' })).xml, [ { op: 'add', kind: 'start', name: 'Received' }, - { op: 'add', kind: 'userTask', name: 'Check', after: 'Event_Received', set: { 'camunda:assignee': 'demo' } }, + { op: 'add', kind: 'userTask', name: 'Check', after: 'startEvent_received', set: { 'camunda:assignee': 'demo' } }, ])).xml); const report = await browser.validateXml(edited.xml); expect(report.platform?.platform).toBe('c7'); expect(JSON.stringify(report)).toBe(JSON.stringify(await core.validateXml(edited.xml))); expect(await browser.showXml(edited.xml)).toBe(await core.showXml(edited.xml)); - expect(await browser.showXml(edited.xml, { id: 'Activity_Check' })).toContain('camunda:assignee'); + expect(await browser.showXml(edited.xml, { id: 'userTask_check' })).toContain('camunda:assignee'); // the reading views of large models (step 3) - expect(await browser.showXml(edited.xml, { around: 'Activity_Check', depth: 1 })).toBe(await core.showXml(edited.xml, { around: 'Activity_Check', depth: 1 })); - expect(await browser.showXml(edited.xml, { id: 'Activity_Check', context: true })).toBe(await core.showXml(edited.xml, { id: 'Activity_Check', context: true })); - expect(await browser.showXml(edited.xml, { around: 'Activity_Check' })).toContain('camunda:assignee=demo'); + expect(await browser.showXml(edited.xml, { around: 'userTask_check', depth: 1 })).toBe(await core.showXml(edited.xml, { around: 'userTask_check', depth: 1 })); + expect(await browser.showXml(edited.xml, { id: 'userTask_check', context: true })).toBe(await core.showXml(edited.xml, { id: 'userTask_check', context: true })); + expect(await browser.showXml(edited.xml, { around: 'userTask_check' })).toContain('camunda:assignee=demo'); expect(await browser.findXml(edited.xml, 'demo')).toEqual(await core.findXml(edited.xml, 'demo')); expect(await browser.metricsXml(edited.xml)).toEqual(await core.metricsXml(edited.xml)); }); @@ -163,10 +163,10 @@ describe('the browser bundle in a context without Node globals', () => { it('creates and validates Camunda 8 files (zeebe descriptor, Camunda 8 profile) like Node', async () => { const ops = [ { op: 'add', kind: 'start', name: 'Received' }, - { op: 'add', kind: 'serviceTask', name: 'Check', after: 'Event_Received' }, - { op: 'ext', id: 'Activity_Check', action: 'add', type: 'loop.zeebe:loopCharacteristics', attrs: { inputCollection: '=items' } }, - { op: 'add', kind: 'userTask', name: 'Approve', after: 'Activity_Check' }, - { op: 'ext', id: 'Activity_Approve', action: 'add', type: 'zeebe:taskDefinition', attrs: { type: 'x' } }, + { op: 'add', kind: 'serviceTask', name: 'Check', after: 'startEvent_received' }, + { op: 'ext', id: 'serviceTask_check', action: 'add', type: 'loop.zeebe:loopCharacteristics', attrs: { inputCollection: '=items' } }, + { op: 'add', kind: 'userTask', name: 'Approve', after: 'serviceTask_check' }, + { op: 'ext', id: 'userTask_approve', action: 'add', type: 'zeebe:taskDefinition', attrs: { type: 'x' } }, ]; const edited = await browser.applyToXml((await browser.newXml({ processName: 'Order', target: 'camunda8' })).xml, ops); expect(edited.xml).toBe((await core.applyToXml((await core.newXml({ processName: 'Order', target: 'camunda8' })).xml, ops)).xml); @@ -193,7 +193,7 @@ describe('the browser bundle in a context without Node globals', () => { const opts = { validators: [host, reached] }; const there = await browser.applyToXml(styled, ops, opts); expect(phases).toEqual(['before:true', 'after:true']); - expect(there.result.created.map((change) => change.id)).toEqual(['Task_AuditOrder', 'Flow_AuditOrderToOrderOk']); + expect(there.result.created.map((change) => change.id)).toEqual(['userTask_auditOrder', 'flow_auditOrderToOrderOk']); expect(there.result.validation.validators?.map((validator) => validator.name)).toEqual(['host', 'reached']); const here = await core.applyToXml(styled, ops, opts); expect(there.xml).toBe(here.xml); diff --git a/test/e2e/pipeline.test.ts b/test/e2e/pipeline.test.ts index be70c795..cdebc3ab 100644 --- a/test/e2e/pipeline.test.ts +++ b/test/e2e/pipeline.test.ts @@ -1,7 +1,7 @@ /** * The pipeline: the four packages of the design-iq round (the * browser-safe core with its in-memory API, the text-preserving writes, the - * file's id conventions and the validator hook) work as one pipeline: + * id conventions and the validator hook) work as one pipeline: * * - the in-memory API runs the text-preserving step: a no-op is `unchanged` * (the input string itself), an edit changes only what it touched; @@ -85,22 +85,21 @@ describe('the in-memory API keeps the text (core + roundtrip + conventions)', () expect(result.result.unchanged).toBe(true); }); - it("an insert gets ids in the file's style and changes only the lines it touched", async () => { + it('an insert gets ids by the naming conventions and changes only the lines it touched', async () => { const result = await applyToXml(STYLED, [{ op: 'add', kind: 'userTask', name: 'Audit order', after: 'Task_Check' }]); expect(result.unchanged).toBe(false); - // Task_ + PascalCase like Task_Check / Task_Script; the file numbers its flows (Flow_1, Flow_2, Flow_4): a new one - // keeps the prefix and names its ends - expect(result.result.created.map((change) => change.id)).toEqual(['Task_AuditOrder', 'Flow_AuditOrderToOrderOk']); + // the default conventions, not the file's Task_ / Flow_ ids: a new flow names its ends + expect(result.result.created.map((change) => change.id)).toEqual(['userTask_auditOrder', 'flow_auditOrderToOrderOk']); // in the process only the spliced flow's target changed (its incoming entry moved to the new task); comments, CDATA and quoting stay expect(removedLines(semantic(STYLED), semantic(result.xml)).map((line) => line.trim())).toEqual(['']); - expect(result.xml).toContain('\n Flow_AuditOrderToOrderOk'); + expect(result.xml).toContain('\n flow_auditOrderToOrderOk'); expect(result.xml.startsWith(STYLED.split('\n Flow_2'); - expect(result.xml).toContain(''); + expect(result.xml).toContain('\n Flow_2'); + expect(result.xml).toContain(''); // new DI in the file's DI id style (_di), the old DI keeps its start tags - expect(result.xml).toContain(''); - expect(result.xml).toContain(''); - expect(diTags(result.xml).filter((tag) => !/Task_AuditOrder|Flow_AuditOrderToOrderOk/.test(tag))).toEqual(diTags(STYLED)); + expect(result.xml).toContain(''); + expect(result.xml).toContain(''); + expect(diTags(result.xml).filter((tag) => !/userTask_auditOrder|flow_auditOrderToOrderOk/.test(tag))).toEqual(diTags(STYLED)); expect(result.xml.endsWith('\n')).toBe(true); }); @@ -129,7 +128,7 @@ describe('validators see what is written (validation + core + roundtrip)', () => const seen: Array<{ phase: string; file?: string; comment: boolean; cdata: boolean }> = []; const spy = (xml: string, ctx: ValidatorContext): ValidatorFinding[] => { seen.push({ phase: ctx.phase, file: ctx.file, comment: xml.includes(''), cdata: xml.includes(' { expect(matches(skill, { skill: 'bpmn' })).toBe(true); expect(matches(skill, { skill: 'pdf' })).toBe(false); const xml = graderOf('build-order-to-cash', 'no-xml-in-shell'); - const cli = `bpmn apply order-to-cash.bpmn - <<'EOF'\n[{ "op": "add", "kind": "userTask", "name": "Check order", "id": "Activity_CheckOrder", "after": "Event_OrderReceived" }]\nEOF`; + const cli = `bpmn apply order-to-cash.bpmn - <<'EOF'\n[{ "op": "add", "kind": "userTask", "name": "Check order", "id": "userTask_checkOrder", "after": "startEvent_orderReceived" }]\nEOF`; expect(matches(xml, { command: cli })).toBe(false); - expect(matches(xml, { command: `bpmn ext add f.bpmn Activity_X zeebe:ioMapping --xml ''` })).toBe(false); + expect(matches(xml, { command: `bpmn ext add f.bpmn serviceTask_x zeebe:ioMapping --xml ''` })).toBe(false); expect(matches(xml, { command: `cat > order-to-cash.bpmn <<'EOF'\n\n` })).toBe(true); expect(matches(xml, { command: `sed -i '' 's/Task_1/Task_2/' order-to-cash.bpmn` })).toBe(true); expect(matches(xml, { command: `python3 -c "import xml.etree.ElementTree as ET"` })).toBe(true); @@ -504,7 +504,7 @@ describe('the eval suite', () => { const built = graderOf('build-order-to-cash', 'built-with-the-cli'); expect(matches(built, { command: `printf '%s' '[]' | bpmn apply order-to-cash.bpmn - --summary` })).toBe(true); expect(matches(built, { command: 'bpmn new order-to-cash.bpmn --name "Order to cash"' })).toBe(true); - expect(matches(built, { command: 'npx -y @miragon/bpmn-cli@1.2.3 add order-to-cash.bpmn userTask "Check order" --after Event_OrderReceived' })).toBe(true); + expect(matches(built, { command: 'npx -y @miragon/bpmn-cli@1.2.3 add order-to-cash.bpmn userTask "Check order" --after startEvent_orderReceived' })).toBe(true); expect(matches(built, { command: 'bpmn show order-to-cash.bpmn' })).toBe(false); expect(matches(built, { command: `cat > order-to-cash.bpmn <<'EOF'\n` })).toBe(false); const validated = graderOf('build-order-to-cash', 'validated'); @@ -555,7 +555,7 @@ describe('the eval suite', () => { const edit = SOLUTIONS['edit-hand-drawn-model']!; const dir = workDir('eval-hash-ids'); expect(spawnSync('bash', [join(EVALS, 'edit-hand-drawn-model', 'scaffold.sh')], { cwd: dir, encoding: 'utf8' }).status).toBe(0); - const hashed = edit.script.replaceAll('Activity_ApprovePurchase', 'Activity_1a7mq4e').replaceAll('Event_ApprovalOverdue', 'Event_1x6rt2w'); + const hashed = edit.script.replaceAll('userTask_approvePurchase', 'Activity_1a7mq4e').replaceAll('event_approvalOverdue', 'Event_1x6rt2w'); const result = sh(hashed, dir); expect(result.code, `${result.out}${result.err}`).toBe(0); const model = readFileSync(join(dir, edit.file), 'utf8'); @@ -570,7 +570,7 @@ describe('the eval suite', () => { expect(green).toMatch(/#205022/i); const otherGreen = green.replace(/#205022/gi, '#43a047'); expect(regexPasses(grader('format-happy-path', 'happy-path-green'), otherGreen)).toBe(true); - const rejectionGreen = otherGreen.replace(/(bpmnElement="Activity_SendRejectionLetter")/, '$1 bioc:stroke="#43a047"'); + const rejectionGreen = otherGreen.replace(/(bpmnElement="sendTask_sendRejectionLetter")/, '$1 bioc:stroke="#43a047"'); expect(regexPasses(grader('format-happy-path', 'rejection-not-green'), rejectionGreen)).toBe(false); }, SLOW); @@ -578,9 +578,9 @@ describe('the eval suite', () => { const caseDir = join(EVALS, 'tidy-after-edit'); const file = 'invoice-approval.bpmn'; const grader = (name: string) => graders(caseDir).find((warning) => warning.name === name)!; - const edit = `bpmn add ${file} sendTask "Inform requester" --id Activity_InformRequester --flow Flow_ReturnInvoiceToInvoiceReturned`; - const branchBelow = `bpmn place ${file} --branch Flow_InvoiceCorrectNo --below Activity_RemindApprover`; - const label = (side: string) => `bpmn label ${file} Gateway_InvoiceCorrect --side ${side}`; + const edit = `bpmn add ${file} sendTask "Inform requester" --id sendTask_informRequester --flow flow_returnInvoiceToSupplierToInvoiceReturned`; + const branchBelow = `bpmn place ${file} --branch flow_invoiceCorrectToReturnInvoiceToSupplier --below sendTask_remindApprover`; + const label = (side: string) => `bpmn label ${file} gateway_invoiceCorrect --side ${side}`; const variants: Record = { 'the fixture': 'true', 'the edit only': edit, @@ -602,16 +602,16 @@ describe('the eval suite', () => { const problems = (JSON.parse(metrics.out) as { problems: { kind: string; ids: string[] }[] }).problems; if (name === 'the fixture') { expect(problems.map((problem) => `${problem.kind} ${problem.ids.join(' ')}`).sort()).toEqual([ - 'crossings Flow_ReturnInvoiceToInvoiceReturned Flow_ApprovalOverdueToRemindApprover', - 'labelClash Gateway_InvoiceCorrect Activity_ApproveInvoice', + 'crossings flow_returnInvoiceToSupplierToInvoiceReturned flow_approvalOverdueToRemindApprover', + 'labelClash gateway_invoiceCorrect userTask_approveInvoice', ]); } if (name.startsWith('the solution')) expect(problems, name).toEqual([]); - const labelProblem = problems.some((problem) => /^label/.test(problem.kind) && problem.ids.includes('Gateway_InvoiceCorrect')); + const labelProblem = problems.some((problem) => /^label/.test(problem.kind) && problem.ids.includes('gateway_invoiceCorrect')); const crossing = problems.some((problem) => problem.kind === 'crossings'); const xml = readFileSync(join(dir, file), 'utf8'); // compact moved the reminder path up: the rows are no longer the solution's - if (name === 'the solution, then compact') expect(xml).toMatch(/bpmnElement="Activity_RemindApprover"[^>]*>\s*]*>\s* { expect(spawnSync('bash', [join(caseDir, 'scaffold.sh')], { cwd: dir, encoding: 'utf8' }).status).toBe(0); const result = sh(`bpmn apply purchase-request.bpmn - <<'EOF' [ - { "op": "add", "kind": "userTask", "name": "Approve purchase", "id": "Activity_ApprovePurchase", "flow": "Flow_0yq6cfd" }, - { "op": "add", "kind": "exclusiveGateway", "name": "Purchase approved?", "id": "Gateway_PurchaseApproved", "after": "Activity_ApprovePurchase" }, - { "op": "connect", "source": "Gateway_PurchaseApproved", "target": "Activity_0f3j5ny", "name": "no", "default": true } + { "op": "add", "kind": "userTask", "name": "Approve purchase", "id": "userTask_approvePurchase", "flow": "Flow_0yq6cfd" }, + { "op": "add", "kind": "exclusiveGateway", "name": "Purchase approved?", "id": "gateway_purchaseApproved", "after": "userTask_approvePurchase" }, + { "op": "connect", "source": "gateway_purchaseApproved", "target": "Activity_0f3j5ny", "name": "no", "default": true } ] EOF`, dir); expect(result.code, `${result.out}${result.err}`).toBe(0); diff --git a/test/e2e/roundtrip.test.ts b/test/e2e/roundtrip.test.ts index d211dfb2..90daba47 100644 --- a/test/e2e/roundtrip.test.ts +++ b/test/e2e/roundtrip.test.ts @@ -112,8 +112,8 @@ describe('text-preserving output (#30, #31, #44)', () => { const out = (await dry(STYLED, [{ op: 'add', kind: 'task', name: 'Audit', id: 'Activity_Audit', after: 'Task_Check' }])).xml; // the spliced flow keeps its place and its tight `/>`, the new elements are indented by the file's four spaces expect(out).toMatch(/\n {8}\n/); - expect(out).toMatch(/\n {8}\n {12}Flow_2<\/bpmn:incoming>\n {12}(Flow_\w+)<\/bpmn:outgoing>\n {8}<\/bpmn:task>\n/); - expect(out).toMatch(/\n {8}\n/); + expect(out).toMatch(/\n {8}\n {12}Flow_2<\/bpmn:incoming>\n {12}(flow_\w+)<\/bpmn:outgoing>\n {8}<\/bpmn:task>\n/); + expect(out).toMatch(/\n {8}\n/); expect(out).toMatch(/\n {12}\n {16}\n {12}<\/bpmndi:BPMNShape>/); // a label written on one line stays on one line when its bounds change expect(out).toMatch(/\n {16}\n {16}<\/bpmndi:BPMNLabel>/); @@ -169,7 +169,7 @@ describe('incoming / outgoing lists are written the way the file keeps them (#45 expect(renamed.xml).toBe(LISTLESS.replace('name="Register request"', 'name="Register the request"')); const inserted = await dry(LISTLESS, [{ op: 'add', kind: 'task', name: 'Check request', after: 'Task_A' }]); expect(lists(inserted.xml)).toBe(0); - expect(inserted.xml).toMatch(/\n {4}\n/); // the file's id style (Task_) + expect(inserted.xml).toMatch(/\n {4}\n/); }); it('in memory the lists are complete (the placement grammar and the views read them)', async () => { @@ -186,14 +186,14 @@ describe('incoming / outgoing lists are written the way the file keeps them (#45 const inserted = await dry(STYLED, [{ op: 'add', kind: 'task', name: 'Audit', id: 'Activity_Audit', after: 'Task_Check' }]); const after = await Doc.fromXml(inserted.xml); expect(after.source!.mirror.listed.get(after.require('Activity_Audit'))).toBeDefined(); - expect(inserted.xml).toMatch(/\n {12}Flow_\w+<\/bpmn:incoming>/); + expect(inserted.xml).toMatch(/\n {12}flow_\w+<\/bpmn:incoming>/); expect(lists(inserted.xml)).toBe(lists(STYLED) + 2); }); it('a file without any sequence flow, like a new one, gets the lists the Modeler writes', async () => { const result = await dry(definitionsXml(' '), [{ op: 'add', kind: 'task', name: 'Work', id: 'Activity_Work', after: 'Start' }], { layout: false }); - expect(result.xml).toMatch(/\s*Flow_\w+<\/bpmn:outgoing>/); - expect(result.xml).toMatch(/\s*Flow_\w+<\/bpmn:incoming>/); + expect(result.xml).toMatch(/\s*flow_\w+<\/bpmn:outgoing>/); + expect(result.xml).toMatch(/\s*flow_\w+<\/bpmn:incoming>/); }); }); diff --git a/test/e2e/summary-and-redirects.test.ts b/test/e2e/summary-and-redirects.test.ts index 5606e8d0..2fe9ebdb 100644 --- a/test/e2e/summary-and-redirects.test.ts +++ b/test/e2e/summary-and-redirects.test.ts @@ -47,30 +47,30 @@ describe('--summary with aliases, refAs, renamed ids and speaking ids', () => { it('lists the message of refAs among the aliases; an element the batch created is not also "changed"', async () => { const base = await newXml({ processName: 'Order', target: 'camunda8' }); const result = await applyToXml(base.xml, [ - { op: 'add', kind: 'startEvent', name: 'Order received', in: 'Process_Order', as: '$s' }, + { op: 'add', kind: 'startEvent', name: 'Order received', in: 'order', as: '$s' }, { op: 'add', kind: 'serviceTask', name: 'Check order', after: '$s', as: '$c' }, { op: 'add', kind: 'intermediateCatchEvent:message', name: 'Payment received', after: '$c', message: 'Payment received', refAs: '$msg', as: '$w' }, { op: 'ext', id: '$msg', action: 'add', type: 'zeebe:subscription', attrs: { correlationKey: '=orderId' } }, { op: 'add', kind: 'exclusiveGateway', after: '$w', as: '$g' }, { op: 'add', kind: 'endEvent', after: '$g', as: '$e' }, ]); - expect(result.result.aliases).toMatchObject({ $msg: 'Message_PaymentReceived', $w: 'Event_PaymentReceived', $g: 'Gateway_AfterPaymentReceived', $e: 'Event_EndAfterPaymentReceived' }); + expect(result.result.aliases).toMatchObject({ $msg: 'message_paymentReceived', $w: 'event_paymentReceived', $g: 'gateway_afterPaymentReceived', $e: 'endEvent_afterPaymentReceivedGateway' }); const summary = mutationSummary(result.result); // before: `changed:` listed the message the batch created (and the ext op then changed) expect(summary.changed).toEqual([]); const text = renderSummary(summary); - expect(text).toContain('aliases: $s = Event_OrderReceived, $c = Activity_CheckOrder, $w = Event_PaymentReceived, $msg = Message_PaymentReceived'); + expect(text).toContain('aliases: $s = startEvent_orderReceived, $c = serviceTask_checkOrder, $w = event_paymentReceived, $msg = message_paymentReceived'); expect(text).not.toMatch(/^changed:/m); }); it('a batch in lanes: created nodes are not "changed" by the lane they got; a later lane change of an existing node is', async () => { const base = await newXml({ processName: 'Shop' }); const built = await applyToXml(base.xml, LANES_BATCH); - // before: every node of the batch was listed under changed too ("- lane Lane_Sales") + // before: every node of the batch was listed under changed too ("- lane lane_sales") expect(mutationSummary(built.result).changed).toEqual([]); - expect(built.result.changed.some((change) => change.id === 'Activity_Check' && /lane/.test(change.detail ?? ''))).toBe(true); - const moved = await applyToXml(built.xml, [{ op: 'set', id: 'Activity_Skip', values: { lane: 'Lane_Billing' } }]); - expect(mutationSummary(moved.result).changed).toContain('Activity_Skip'); + expect(built.result.changed.some((change) => change.id === 'task_check' && /lane/.test(change.detail ?? ''))).toBe(true); + const moved = await applyToXml(built.xml, [{ op: 'set', id: 'task_skip', values: { lane: 'lane_billing' } }]); + expect(mutationSummary(moved.result).changed).toContain('task_skip'); }); }); @@ -78,17 +78,17 @@ describe('Camunda 8: the subscription redirect and the warnings delta', () => { it('a subscription given to a receive task goes to its message, says so, and resolves the deploy finding once', async () => { const base = await newXml({ processName: 'Inv', target: 'camunda8' }); const built = await applyToXml(base.xml, [ - { op: 'add', kind: 'startEvent', name: 'Go', in: 'Process_Inv', as: '$s' }, + { op: 'add', kind: 'startEvent', name: 'Go', in: 'inv', as: '$s' }, { op: 'add', kind: 'receiveTask', name: 'Wait for invoice', after: '$s', message: 'Invoice received', as: '$w' }, { op: 'add', kind: 'endEvent', name: 'Done', after: '$w' }, ]); expect(built.result.warnings.added.map((warning) => warning.code)).toEqual(['W_C8_DEPLOY_MESSAGE']); - const result = await applyToXml(built.xml, [{ op: 'ext', id: 'Activity_WaitForInvoice', action: 'add', type: 'zeebe:subscription', attrs: { correlationKey: '=invoiceId' } }]); - expect(result.result.notes.join('\n')).toContain('zeebe:subscription goes to message Message_InvoiceReceived, which Activity_WaitForInvoice waits for'); + const result = await applyToXml(built.xml, [{ op: 'ext', id: 'receiveTask_waitForInvoice', action: 'add', type: 'zeebe:subscription', attrs: { correlationKey: '=invoiceId' } }]); + expect(result.result.notes.join('\n')).toContain('zeebe:subscription goes to message message_invoiceReceived, which receiveTask_waitForInvoice waits for'); expect(result.result.warnings.added).toEqual([]); expect(result.result.warnings.resolved.map((warning) => warning.code)).toEqual(['W_C8_DEPLOY_MESSAGE']); expect(result.result.warnings.preexistingCount).toBe(0); - expect(mutationSummary(result.result).changed).toEqual(['Message_InvoiceReceived']); + expect(mutationSummary(result.result).changed).toEqual(['message_invoiceReceived']); }); }); @@ -96,23 +96,23 @@ describe('remove and move with speaking flow ids', () => { it('a plain remove of a merge bridges each path and renames the flows after their new ends', async () => { const base = await newXml({ processName: 'Shop' }); const built = await applyToXml(base.xml, LANES_BATCH); - expect(built.result.aliases?.['$j']).toBe('Gateway_Ok_join'); - const result = await applyToXml(built.xml, [{ op: 'remove', ids: ['Gateway_Ok_join'] }]); - expect(result.result.renamed).toEqual({ Flow_FixToOkJoin: 'Flow_FixToShip', Flow_SkipToOkJoin: 'Flow_SkipToShip' }); - expect(flowIds(result.xml)).toEqual(expect.arrayContaining(['Flow_FixToShip', 'Flow_SkipToShip'])); + expect(built.result.aliases?.['$j']).toBe('gateway_okJoin'); + const result = await applyToXml(built.xml, [{ op: 'remove', ids: ['gateway_okJoin'] }]); + expect(result.result.renamed).toEqual({ flow_fixToOkJoin: 'flow_fixToShip', flow_skipToOkJoin: 'flow_skipToShip' }); + expect(flowIds(result.xml)).toEqual(expect.arrayContaining(['flow_fixToShip', 'flow_skipToShip'])); expect(result.result.warnings.added.map((warning) => warning.code)).not.toContain('W_UNREACHABLE'); - expect(renderSummary(mutationSummary(result.result))).toContain('renamed: Flow_FixToOkJoin -> Flow_FixToShip, Flow_SkipToOkJoin -> Flow_SkipToShip'); + expect(renderSummary(mutationSummary(result.result))).toContain('renamed: flow_fixToOkJoin -> flow_fixToShip, flow_skipToOkJoin -> flow_skipToShip'); }); it('a plain remove of a parallel join is refused with both commands', async () => { const base = await newXml({ processName: 'Par' }); const built = await applyToXml(base.xml, [ - { op: 'add', kind: 'startEvent', name: 'Start', in: 'Process_Par', as: '$s' }, + { op: 'add', kind: 'startEvent', name: 'Start', in: 'par', as: '$s' }, { op: 'split', after: '$s', kind: 'parallelGateway', joinAs: '$j', branches: [{ nodes: [{ kind: 'task', name: 'Pack' }] }, { nodes: [{ kind: 'task', name: 'Bill' }] }] }, { op: 'add', kind: 'endEvent', name: 'Done', after: '$j' }, ]); const join = built.result.aliases!['$j']!; - expect(join).toBe('Gateway_AfterStart_join'); + expect(join).toBe('gateway_afterStartJoin'); const err = await rejection(applyToXml(built.xml, [{ op: 'remove', ids: [join] }])); expect(err.code).toBe('E_AMBIGUOUS_BRIDGE'); expect(err.details.hint).toContain(`bpmn remove ${join} --bridge-all`); @@ -126,12 +126,12 @@ describe('remove and move with speaking flow ids', () => { { op: 'add', kind: 'subProcess', name: 'Archive', after: '$ship', as: '$sub' }, { op: 'add', kind: 'task', name: 'Scan', in: '$sub', as: '$scan' }, ]); - expect(flowIds(built.xml)).toContain('Flow_OkToFix'); - const result = await applyToXml(built.xml, [{ op: 'move', ids: ['Activity_Scan'], flow: 'Flow_OkToFix' }]); + expect(flowIds(built.xml)).toContain('flow_okToFix'); + const result = await applyToXml(built.xml, [{ op: 'move', ids: ['task_scan'], flow: 'flow_okToFix' }]); // the node had no lane (it was in a sub-process): the lane of the row it is drawn on, W_LANE_INHERITED expect(result.result.warnings.added.map((warning) => warning.code)).toContain('W_LANE_INHERITED'); - expect(result.xml).toMatch(/]*>\s*Activity_Fix<\/bpmn:flowNodeRef>\s*Activity_Scan<\/bpmn:flowNodeRef>/); - expect(result.result.renamed).toEqual({ Flow_OkToFix: 'Flow_OkToScan' }); - expect(flowIds(result.xml)).toEqual(expect.arrayContaining(['Flow_OkToScan', 'Flow_ScanToFix'])); + expect(result.xml).toMatch(/]*>\s*task_fix<\/bpmn:flowNodeRef>\s*task_scan<\/bpmn:flowNodeRef>/); + expect(result.result.renamed).toEqual({ flow_okToFix: 'flow_okToScan' }); + expect(flowIds(result.xml)).toEqual(expect.arrayContaining(['flow_okToScan', 'flow_scanToFix'])); }); }); diff --git a/test/fixtures/mixed-styles/expand-sibling-9473.bpmn b/test/fixtures/mixed-styles/expand-sibling-9473.bpmn deleted file mode 100644 index c4787be3..00000000 --- a/test/fixtures/mixed-styles/expand-sibling-9473.bpmn +++ /dev/null @@ -1,63 +0,0 @@ - - - - - - F2 - G1 - G1G2 - G2 - - - - G_Fz6ArchiveToThenF3 - - - - - - F2 - G_Fz6ArchiveToThen - - - - - F3 - G_Fz3CheckToE_2 - - G_Fz3CheckToE_2 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/test/fixtures/mixed-styles/polish-lanes-7392.bpmn b/test/fixtures/mixed-styles/polish-lanes-7392.bpmn deleted file mode 100644 index 31b3b5f3..00000000 --- a/test/fixtures/mixed-styles/polish-lanes-7392.bpmn +++ /dev/null @@ -1,83 +0,0 @@ - - - - - - - - SG1T1EActivity_Fz1ArchiveGateway_Fz5Check - T2Activity_Fz2NotifyJ - - f1 - f4 - f4f6fJToFz2Notify - f6 - - - f1 - - - - - fGatewayToFz2Notify - - - fJToFz2Notifyf5 - - - f5 - fFz5CheckToGateway - - - fFz5CheckToGatewayfGatewayToFz2Notify - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/test/fixtures/mixed-styles/polish-remove-9456.bpmn b/test/fixtures/mixed-styles/polish-remove-9456.bpmn deleted file mode 100644 index 3eaa33a1..00000000 --- a/test/fixtures/mixed-styles/polish-remove-9456.bpmn +++ /dev/null @@ -1,46 +0,0 @@ - - - - - - - - f3f4 - fFz3PayToEnd - - - f4fHandleTicketToFz3Pay - f3 - - - fHandleTicketToFz3Pay - fFz3PayToEnd - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/test/fixtures/mixed-styles/pool-branch-7922.bpmn b/test/fixtures/mixed-styles/pool-branch-7922.bpmn deleted file mode 100644 index 8f6d7cca..00000000 --- a/test/fixtures/mixed-styles/pool-branch-7922.bpmn +++ /dev/null @@ -1,79 +0,0 @@ - - - - - - - - SSUBG - T_UPE1T_LOWE2 - Gateway_Fz2Archive - - - - F_SToFz2Archive - - - F_SToFz2Archive - - - F5 - F6 - F6 - - F5F_CarrierToSUBF_CarrierToEnd_2 - F_CarrierToSUB - G1 - G1G2 - G2 - - - - - F_CarrierToEnd_2 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/test/fixtures/mixed-styles/scope-tie.bpmn b/test/fixtures/mixed-styles/scope-tie.bpmn deleted file mode 100644 index 46d0d115..00000000 --- a/test/fixtures/mixed-styles/scope-tie.bpmn +++ /dev/null @@ -1,15 +0,0 @@ - - - - Flow_Ord_ReceiveToCheck - Flow_Ord_ReceiveToCheckFlow_Ord_CheckToShip - Flow_Ord_CheckToShip - Flow_Bil_InvoiceToBook - Flow_Bil_InvoiceToBookFlow_Bil_BookToClose - Flow_Bil_BookToClose - - - - - - diff --git a/test/helpers/bpmnlint.ts b/test/helpers/bpmnlint.ts index 86b1bf2b..7ba11b7e 100644 --- a/test/helpers/bpmnlint.ts +++ b/test/helpers/bpmnlint.ts @@ -42,11 +42,11 @@ export async function model(ops: unknown[], name = 'Order'): Promise { return (await applyToXml((await newXml({ processName: name })).xml, ops)).xml; } -/** start "Received" -> userTask "Check" -> end "Done" in Process_Order. */ +/** start "Received" -> userTask "Check" -> end "Done" in the process order. */ export const LINE = [ { op: 'add', kind: 'start', name: 'Received' }, - { op: 'add', kind: 'userTask', name: 'Check', after: 'Event_Received' }, - { op: 'add', kind: 'end', name: 'Done', after: 'Activity_Check' }, + { op: 'add', kind: 'userTask', name: 'Check', after: 'startEvent_received' }, + { op: 'add', kind: 'end', name: 'Done', after: 'userTask_check' }, ]; /** A moddle descriptor BpmnModdle takes, and fails on when it parses any file: a property that redefines one bpmn has not. */ diff --git a/tools/speaking-ids.mjs b/tools/speaking-ids.mjs deleted file mode 100644 index adc10e71..00000000 --- a/tools/speaking-ids.mjs +++ /dev/null @@ -1,136 +0,0 @@ -#!/usr/bin/env node -/** - * Speaking ids on a corpus (aggregate counts only: a private corpus stays - * private). Every probe is a dry run in-process (dist/index.js): - * - * npm run build - * node tools/speaking-ids.mjs ... # tools/scenarios when none - * node tools/speaking-ids.mjs --baseline /dist/index.js - * - * Per file with a task that has one outgoing flow (the anchor) it adds a - * named user task after it, an unnamed parallel gateway after it, and an - * unnamed timer boundary event on it, and reports: - * - speaking: the new ids (created, and the new ids of renamed flows) that - * are no Camunda Modeler hash, no number and no glued number; - * - renamed: flows whose ids named their old ends (src/domain/editing/connections/FlowIdFollower.ts); - * - with --baseline: the new ids whose prefix equals the one the other - * build gives the same element (step 2 learned the prefixes); - * - collisions: two independent edits of the file at the first and the - * last anchor (a different named task plus an unnamed gateway; the same - * name; an unnamed gateway plus a boundary event) that share a new id - * (a merge of the two branches would have a duplicate id). - */ -import { readdirSync, statSync } from 'node:fs'; -import { readFileSync } from 'node:fs'; -import { dirname, join, resolve } from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; - -const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); -const args = process.argv.slice(2); -let baseline; -const inputs = []; -for (let argIndex = 0; argIndex < args.length; argIndex++) { - if (args[argIndex] === '--baseline') baseline = args[++argIndex]; - else inputs.push(args[argIndex]); -} -if (!inputs.length) inputs.push(join(ROOT, 'tools', 'scenarios')); - -const lib = await import(pathToFileURL(join(ROOT, 'dist', 'index.js')).href); -const base = baseline ? await import(pathToFileURL(resolve(baseline)).href) : undefined; - -function files(path) { - if (statSync(path).isFile()) return [path]; - return readdirSync(path, { recursive: true }) - .map((entry) => join(path, String(entry))) - .filter((candidate) => candidate.endsWith('.bpmn') && statSync(candidate).isFile()); -} - -const TASKS = ['task', 'userTask', 'serviceTask', 'scriptTask', 'sendTask', 'receiveTask', 'manualTask', 'businessRuleTask']; -const attr = (tag, name) => new RegExp(`\\s${name}="([^"]*)"`).exec(tag)?.[1]; -const prefixOf = (id) => /^([A-Za-z][A-Za-z0-9]*)_/.exec(id)?.[1] ?? /^([A-Za-z]+?)(?=[A-Z0-9])/.exec(id)?.[1] ?? ''; -const kindKey = (kind) => (kind === 'sequenceFlow' ? 'flow' : TASKS.includes(kind) ? 'task' : kind.endsWith('Gateway') ? 'gateway' : kind.includes('Event') ? 'event' : 'other'); - -/** A hash, a number or a glued number says nothing; a collision suffix after a speaking body is fine. */ -function speaking(id) { - const body = (/^[A-Za-z][A-Za-z0-9]*_(.+)$/.exec(id)?.[1] ?? id).replace(/_\d+$/, ''); - if (/^[01][0-9a-z]{6}$/.test(body) || /^\d+$/.test(body) || /^[A-Za-z]+\d+$/.test(id)) return false; - return /[A-Za-z]{2}/.test(body); -} - -/** The ids an edit brought into the file, in order: created ones and the new ids of renamed flows. */ -function newIds(changes) { - const out = changes.created.map((change) => ({ id: change.id, kind: change.kind })); - for (const change of changes.changed) if (/renamed from /.test(change.detail ?? '')) out.push({ id: change.id, kind: change.kind, renamed: true }); - return out; -} - -const run = async (cliModule, xml, ops) => (await cliModule.mutateDoc(await cliModule.Doc.fromXml(xml), ops, { dryRun: true, force: true, layout: false, platform: 'none' })).changes; -const pct = (count, total) => `${count}/${total} (${total ? ((100 * count) / total).toFixed(1) : 0} %)`; - -const st = { files: 0, probed: 0, failed: 0, kinds: new Map(), renamed: 0, pairs: { different: [0, 0, 0], sameName: [0, 0, 0], unnamed: [0, 0, 0] } }; -for (const file of inputs.flatMap(files)) { - const xml = readFileSync(file, 'utf8'); - st.files++; - const outgoing = new Map(); - for (const match of xml.matchAll(/<(?:[A-Za-z0-9]+:)?sequenceFlow\b[^>]*>/g)) outgoing.set(attr(match[0], 'sourceRef'), (outgoing.get(attr(match[0], 'sourceRef')) ?? 0) + 1); - const anchors = [...xml.matchAll(new RegExp(`<(?:[A-Za-z0-9]+:)?(${TASKS.join('|')})\\b[^>]*>`, 'g'))].map((match) => attr(match[0], 'id')).filter((id) => id && outgoing.get(id) === 1); - if (!anchors.length) continue; - st.probed++; - const anchor = anchors[0]; - const probes = [[{ op: 'add', kind: 'userTask', name: 'Pack goods', after: anchor }], [{ op: 'add', kind: 'parallelGateway', after: anchor }], [{ op: 'add', kind: 'boundaryEvent:timer', on: anchor, timer: 'PT1H' }]]; - try { - for (const ops of probes) { - const mine = newIds(await run(lib, xml, ops)); - const theirs = base ? newIds(await run(base, xml, ops)).filter((theirId) => !theirId.renamed) : undefined; - mine.forEach((newId, idIndex) => { - const kindName = kindKey(newId.kind); - let kindStats = st.kinds.get(kindName); - if (!kindStats) st.kinds.set(kindName, (kindStats = { n: 0, speaking: 0, compared: 0, samePrefix: 0 })); - kindStats.n++; - if (speaking(newId.id)) kindStats.speaking++; - if (newId.renamed) st.renamed++; - const other = theirs && !newId.renamed ? theirs[idIndex] : undefined; - if (other && other.kind === newId.kind) { - kindStats.compared++; - if (prefixOf(other.id) === prefixOf(newId.id)) kindStats.samePrefix++; - } - }); - } - } catch { - st.failed++; - continue; - } - if (anchors.length < 2) continue; - const leftAnchor = anchors[0]; - const rightAnchor = anchors[anchors.length - 1]; - const pairs = { - different: [[{ op: 'add', kind: 'userTask', name: 'Left branch', after: leftAnchor }, { op: 'add', kind: 'parallelGateway', after: leftAnchor }], [{ op: 'add', kind: 'userTask', name: 'Right branch', after: rightAnchor }, { op: 'add', kind: 'parallelGateway', after: rightAnchor }]], - sameName: [[{ op: 'add', kind: 'userTask', name: 'Review', after: leftAnchor }], [{ op: 'add', kind: 'userTask', name: 'Review', after: rightAnchor }]], - unnamed: [[{ op: 'add', kind: 'exclusiveGateway', after: leftAnchor }, { op: 'add', kind: 'boundaryEvent:timer', on: leftAnchor, timer: 'PT1H' }], [{ op: 'add', kind: 'exclusiveGateway', after: rightAnchor }, { op: 'add', kind: 'boundaryEvent:timer', on: rightAnchor, timer: 'PT1H' }]], - }; - for (const [name, [opsL, opsR]] of Object.entries(pairs)) { - try { - const leftIds = newIds(await run(lib, xml, opsL)).map((newId) => newId.id); - const rightIds = newIds(await run(lib, xml, opsR)).map((newId) => newId.id); - const shared = leftIds.filter((id) => rightIds.includes(id)); - st.pairs[name][0]++; - if (shared.length) st.pairs[name][1]++; - st.pairs[name][2] += shared.length; - } catch {} - } -} - -let total = 0; -let sp = 0; -let cmp = 0; -let same = 0; -console.log(`files ${st.files}, probed ${st.probed}, failed ${st.failed}`); -for (const [kindName, kindStats] of st.kinds) { - total += kindStats.n; - sp += kindStats.speaking; - cmp += kindStats.compared; - same += kindStats.samePrefix; - console.log(` ${kindName.padEnd(8)} speaking ${pct(kindStats.speaking, kindStats.n)}${base ? `, prefix as the baseline ${pct(kindStats.samePrefix, kindStats.compared)}` : ''}`); -} -console.log(`new ids speaking ${pct(sp, total)}${base ? `; prefix as the baseline ${pct(same, cmp)}` : ''}; renamed flows ${st.renamed}`); -for (const [name, [pairs, colliding, shared]] of Object.entries(st.pairs)) console.log(`two edits, ${name.padEnd(9)} ${pct(colliding, pairs)} share a new id (${shared} id(s))`);