Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 13 additions & 11 deletions packages/core/graph/projection/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,17 +41,19 @@ A message belongs to an operand when it arrives at an element in that operand's

Every projection result carries `loss`, what it did not carry over from the model. Each `Loss` is a discriminated union on `kind`, reported only when something was actually dropped, in this order:

| `kind` | Dropped | Reported by |
| ------------------ | ------------------------------------------------------------------------------- | ---------------- |
| `layout` | Positions recorded in `views` for this projection's own notation. | every projection |
| `extensions` | Notation-only `extensions` on elements, which no projection shows. | every projection |
| `message-elements` | `message` elements, which no projection draws. | every projection |
| `fragments` | Authored fragments the projection does not draw. | every projection |
| `subject` | Activities left out because they do not affect the state machine's subject. | `toStateMachine` |
| `scenario` | Elements of the model that are not on the scenario. | `toSequence` |
| `unshown-branches` | Transitions leaving an element on the scenario that the scenario does not take. | `toSequence` |

A flowchart drops ownership, a state machine needs its subject recorded, and a sequence diagram follows one path, so one model yields a different diagram per scenario.
| `kind` | Dropped | Reported by |
| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- |
| `layout` | Positions recorded in `views` for this projection's own notation. | every projection |
| `extensions` | Notation-only `extensions` on elements, which no projection shows. | every projection |
| `message-elements` | `message` elements, which no projection draws. | every projection |
| `fragments` | Authored fragments the projection does not draw. | every projection |
| `unrepresented-elements` | Elements the notation has no way to draw (`state`, `history`, and in a state machine `fork` and `join`), with the transitions touching them. | `toFlowchart`, `toSwimlane`, `toStateMachine` |
| `subject` | Activities left out because they do not affect the state machine's subject. | `toStateMachine` |
| `scenario` | Elements of the model that are not on the scenario. | `toSequence` |
| `unshown-branches` | Transitions leaving an element on the scenario that the scenario does not take. | `toSequence` |
| `skipped-steps` | Scenario steps that touch an element with no actor and so draw no message. | `toSequence` |

Decisions a state machine folds into guards are by design and not a loss. A flowchart drops ownership, a state machine needs its subject recorded, and a sequence diagram follows one path, so one model yields a different diagram per scenario.

## Commands

Expand Down
27 changes: 24 additions & 3 deletions packages/core/graph/projection/src/flowchart.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { IdSpace, Model } from "@exadev/process-graph-schema";
import type { ElementOf } from "./actors.ts";
import { commonLoss, type Loss } from "./loss.ts";
import { commonLoss, unrepresentedLoss, type Loss } from "./loss.ts";

export interface FlowNode<I extends IdSpace> {
readonly id: I["element"];
Expand Down Expand Up @@ -51,7 +51,28 @@ const flowNode = <I extends IdSpace>(
return undefined;
};

/** Activities, decisions and terminators, joined by the transitions between them. */
/**
* What a flowchart-shaped projection drops: the common losses, then the elements the flowchart has no node for (`state` elements and `history` pseudo-states) and the transitions that touch them.
* @param model - The model projected.
* @param notation - The notation the result is for, `flowchart` or `swimlane`.
* @param nodes - The nodes the projection drew.
*/
export const flowchartLoss = <I extends IdSpace>(
model: Model<I>,
notation: "flowchart" | "swimlane",
nodes: readonly FlowNode<I>[],
): readonly Loss<I>[] => [
...commonLoss(model, notation),
...unrepresentedLoss(
model,
notation,
new Set<I["element"]>(nodes.map((node) => node.id)),
),
];

/**
* Activities, decisions and terminators, joined by the transitions between them. `state` elements and `history` pseudo-states have no flowchart shape and are reported as unrepresented, with the transitions that touch them.
*/
export const toFlowchart = <I extends IdSpace>(
model: Model<I>,
): Flowchart<I> => {
Expand Down Expand Up @@ -79,5 +100,5 @@ export const toFlowchart = <I extends IdSpace>(
}
}

return { nodes, edges, loss: commonLoss(model, "flowchart") };
return { nodes, edges, loss: flowchartLoss(model, "flowchart", nodes) };
};
2 changes: 1 addition & 1 deletion packages/core/graph/projection/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Projections of the process model into each notation: hand-offs, flowchart, swimlane, state machine and sequence diagram. Each is a pure function of the model plus the extra input its notation needs (none, a subject entity, or a scenario of transition ids).
*/
export type { Loss } from "./loss.ts";
export type { Loss, UnrepresentedElement, UnrepresentedKind } from "./loss.ts";
export { handoffs, type Handoff } from "./handoffs.ts";
export {
toFlowchart,
Expand Down
67 changes: 65 additions & 2 deletions packages/core/graph/projection/src/loss.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,21 @@
import type { IdSpace, Model, Notation } from "@exadev/process-graph-schema";
import type {
IdSpace,
Model,
Notation,
PseudoRole,
} from "@exadev/process-graph-schema";

/** What an element that a notation cannot draw is: an element kind, or the role of a pseudo-state. */
export type UnrepresentedKind = "activity" | "decision" | "state" | PseudoRole;

/** An element a projection could not draw, and what it was. */
export interface UnrepresentedElement<I extends IdSpace> {
readonly element: I["element"];
readonly kind: UnrepresentedKind;
}

/**
* Something a projection does not carry from the model into its result. A projection returns every loss that applies, in this order: layout, extensions, message elements, fragments, then the notation's own choice (subject, scenario, unshown branches). A kind is only reported when something was actually dropped.
* Something a projection does not carry from the model into its result. A projection returns every loss that applies, in this order: layout, extensions, message elements, fragments, then the notation's own choice (unrepresented elements, subject, scenario, unshown branches, skipped steps). A kind is only reported when something was actually dropped.
*/
export type Loss<I extends IdSpace> =
/** Positions recorded for this projection's notation in `views`. No projection reads or keeps coordinates. */
Expand All @@ -18,6 +32,13 @@ export type Loss<I extends IdSpace> =
readonly kind: "fragments";
readonly fragments: readonly I["fragment"][];
}
/** Elements, other than messages, that the notation has no way to draw, and the transitions left out because one end of them is such an element. */
| {
readonly kind: "unrepresented-elements";
readonly notation: Notation;
readonly elements: readonly UnrepresentedElement<I>[];
readonly transitions: readonly I["transition"][];
}
/** Activities left out of a state machine because they do not affect its subject. */
| {
readonly kind: "subject";
Expand All @@ -30,8 +51,50 @@ export type Loss<I extends IdSpace> =
| {
readonly kind: "unshown-branches";
readonly transitions: readonly I["transition"][];
}
/** Scenario steps that produced no message because an end of the transition has no actor. */
| {
readonly kind: "skipped-steps";
readonly transitions: readonly I["transition"][];
};

/**
* The elements the notation cannot draw: every element that is neither in `accounted` (drawn, or folded away by design, as a decision is in a state machine) nor a `message`, which is reported as `message-elements` by every projection.
* @param model - The model being projected.
* @param notation - The notation that cannot draw them.
* @param accounted - Elements the projection draws or folds away by design.
* @returns A single `unrepresented-elements` loss, or none when every element is accounted for.
*/
export const unrepresentedLoss = <I extends IdSpace>(
model: Model<I>,
notation: Notation,
accounted: ReadonlySet<I["element"]>,
): readonly Loss<I>[] => {
const elements: UnrepresentedElement<I>[] = [];
for (const element of model.elements) {
if (element.kind === "message" || accounted.has(element.id)) {
continue;
}
elements.push({
element: element.id,
kind: element.kind === "pseudo" ? element.role : element.kind,
});
}
if (elements.length === 0) {
return [];
}

const lost = new Set<I["element"]>(elements.map((entry) => entry.element));
const transitions = model.transitions
.filter(
(transition) =>
lost.has(transition.source) || lost.has(transition.target),
)
.map((transition) => transition.id);

return [{ kind: "unrepresented-elements", notation, elements, transitions }];
};

/** The losses every projection can have, whatever its notation: dropped layout, notation-only extensions, message elements and authored fragments. */
export const commonLoss = <I extends IdSpace>(
model: Model<I>,
Expand Down
141 changes: 141 additions & 0 deletions packages/core/graph/projection/src/loss.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,4 +119,145 @@ describe("loss reports", () => {

expect(toFlowchart(bare).loss).toEqual([]);
});

describe("elements a notation cannot draw", () => {
/** A `state` and a `history` pseudo-state, which no flowchart shape exists for, plus a fork that a flowchart draws and a state machine does not. */
const stateful = defineModel({
participants: [
{ id: "customer", label: "Customer", kind: "role" },
{ id: "order", label: "Order", kind: "entity" },
],
events: [],
actions: [],
artefacts: [],
elements: [
{ kind: "pseudo", id: "start", role: "initial" },
{ kind: "activity", id: "a-pay", label: "Pay", actor: "customer" },
{ kind: "state", id: "s-paid", label: "Paid" },
{ kind: "pseudo", id: "h", role: "history" },
{ kind: "pseudo", id: "fork", role: "fork" },
{ kind: "pseudo", id: "end", role: "final" },
],
transitions: [
{ id: "t1", source: "start", target: "a-pay" },
{ id: "t2", source: "a-pay", target: "s-paid" },
{ id: "t3", source: "s-paid", target: "h" },
{ id: "t4", source: "a-pay", target: "fork" },
{ id: "t5", source: "fork", target: "end" },
],
fragments: [],
projections: [],
});

it("reports the state and the history pseudo-state a flowchart drops, with their transitions", () => {
const flow = toFlowchart(stateful);

expect(flow.nodes.map((node) => node.id)).toEqual([
"start",
"a-pay",
"fork",
"end",
]);
expect(flow.loss).toEqual([
{
kind: "unrepresented-elements",
notation: "flowchart",
elements: [
{ element: "s-paid", kind: "state" },
{ element: "h", kind: "history" },
],
transitions: ["t2", "t3"],
},
]);
});

it("reports the same drop for a swimlane under its own notation", () => {
expect(toSwimlane(stateful).loss).toEqual([
{
kind: "unrepresented-elements",
notation: "swimlane",
elements: [
{ element: "s-paid", kind: "state" },
{ element: "h", kind: "history" },
],
transitions: ["t2", "t3"],
},
]);
});

it("reports the fork and history a state machine has no state for, and leaves no transition pointing at them", () => {
const machine = toStateMachine(stateful, "order");

expect(machine.states.map((state) => state.id)).toEqual([
"start",
"a-pay",
"s-paid",
"end",
]);
expect(machine.transitions.map((t) => [t.source, t.target])).toEqual([
["start", "a-pay"],
["a-pay", "s-paid"],
]);
expect(machine.loss).toEqual([
{
kind: "unrepresented-elements",
notation: "stateMachine",
elements: [
{ element: "h", kind: "history" },
{ element: "fork", kind: "fork" },
],
transitions: ["t3", "t4", "t5"],
},
]);
});

it("does not report a decision a state machine folds into guards", () => {
const decided = defineModel({
participants: [{ id: "order", label: "Order", kind: "entity" }],
events: [],
actions: [],
artefacts: [],
elements: [
{ kind: "activity", id: "a", label: "A" },
{ kind: "decision", id: "d", label: "D" },
{ kind: "activity", id: "b", label: "B" },
],
transitions: [
{ id: "t1", source: "a", target: "d" },
{ id: "t2", source: "d", target: "b", guard: "yes" },
],
fragments: [],
projections: [],
});

expect(toStateMachine(decided, "order").loss).toEqual([]);
});
});

it("reports scenario steps that touch an element with no actor as skipped", () => {
const partial = defineModel({
participants: [
{ id: "customer", label: "Customer", kind: "role" },
{ id: "shop", label: "Shop", kind: "system" },
],
events: [],
actions: [],
artefacts: [],
elements: [
{ kind: "pseudo", id: "start", role: "initial" },
{ kind: "activity", id: "a", label: "A", actor: "customer" },
{ kind: "activity", id: "b", label: "B", actor: "shop" },
],
transitions: [
{ id: "t1", source: "start", target: "a" },
{ id: "t2", source: "a", target: "b" },
],
fragments: [],
projections: [],
});

expect(toSequence(partial, ["t1", "t2"]).loss).toEqual([
{ kind: "skipped-steps", transitions: ["t1"] },
]);
});
});
2 changes: 2 additions & 0 deletions packages/core/graph/projection/src/projections.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ describe("the order process", () => {
loss: [
{ kind: "scenario", elements: ["a-cancel"] },
{ kind: "unshown-branches", transitions: ["t6"] },
{ kind: "skipped-steps", transitions: ["t1", "t7"] },
],
});
});
Expand All @@ -185,6 +186,7 @@ describe("the order process", () => {
expect(diagram.loss).toEqual([
{ kind: "scenario", elements: ["a-pack"] },
{ kind: "unshown-branches", transitions: ["t5"] },
{ kind: "skipped-steps", transitions: ["t1", "t8"] },
]);
expect(diagram.messages.map((message) => message.label)).toEqual([
"Request payment",
Expand Down
8 changes: 7 additions & 1 deletion packages/core/graph/projection/src/sequence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ const wrapFragments = <I extends IdSpace>(
};

/**
* One path through the process as messages. A step between two elements with actors becomes a message, labelled by the transition's event when it has one and by the target's label otherwise. Steps touching an element with no actor are skipped. Branches off the path are not drawn, which is why this direction is lossy and one-to-many.
* One path through the process as messages. A step between two elements with actors becomes a message, labelled by the transition's event when it has one and by the target's label otherwise. Steps touching an element with no actor are skipped and reported as `skipped-steps`. Branches off the path are not drawn, which is why this direction is lossy and one-to-many.

`messages` is the flat list. `items` is the same messages with the authored fragments the scenario passes through wrapped around the ones that fall inside them; a fragment that cannot be drawn faithfully is left out and reported in `loss`.
*/
Expand All @@ -203,6 +203,7 @@ export const toSequence = <I extends IdSpace>(
model.events.map((event) => [event.id, event.name]),
);

const skipped: I["transition"][] = [];
const lifelines: I["actor"][] = [];
const messages: SequenceMessage<I>[] = [];
const entries: Entry<I>[] = [];
Expand All @@ -214,6 +215,7 @@ export const toSequence = <I extends IdSpace>(
const from = actors.get(transition.source);
const to = actors.get(transition.target);
if (from === undefined || to === undefined) {
skipped.push(id);
continue;
}
for (const participant of [from, to]) {
Expand Down Expand Up @@ -269,5 +271,9 @@ export const toSequence = <I extends IdSpace>(
});
}

if (skipped.length > 0) {
loss.push({ kind: "skipped-steps", transitions: skipped });
}

return { lifelines, messages, items, loss };
};
Loading
Loading