diff --git a/client/src/webview/diagram-focus.spec.ts b/client/src/webview/diagram-focus.spec.ts
index 7f9a2a0..baada7d 100644
--- a/client/src/webview/diagram-focus.spec.ts
+++ b/client/src/webview/diagram-focus.spec.ts
@@ -118,7 +118,7 @@ describe('state error focus', () => {
expect(lines(createMermaidDiagram(sm, 'TB', false, false))).toContain('[*] --> maybeHasChildren : isLeaf');
expect(lines(createMermaidDiagram(sm, 'TB', true, false))).toContain(
- '[*] --> maybeHasChildren : isLeaf !return');
+ '[*] --> maybeHasChildren : isLeaf [!return]');
// the constructor and the entry transition come first
expect(dimmedTransitions(createMermaidDiagram(sm, 'TB', false, true))[0]).toMatch(/^%% lj-dimmed-transitions: edge0 edge1\b/);
});
@@ -130,8 +130,8 @@ describe('state error focus', () => {
] };
expect(lines(createMermaidDiagram(sm, 'TB', true, true))).toContain(`[*] --> maybeHasChildren : ${collapsedMethods([
- 'isLeaf !return',
- 'getChildCount return > 0',
+ 'isLeaf [!return]',
+ 'getChildCount [return > 0]',
], '2 methods')}`);
});
@@ -176,7 +176,7 @@ describe('state error focus', () => {
sm.transitions[0] = { ...sm.transitions[0], fromCondition: 'unbound(this)' };
expect(lines(createMermaidDiagram(sm, 'TB', true, true))).toContain(
- `unbound --> bound : ${dimmedMethod('unbound(this) bind')}`);
+ `unbound --> bound : ${dimmedMethod('[unbound(this)] bind')}`);
});
it('only colours labels that match the called method exactly', () => {
@@ -247,6 +247,7 @@ describe('state error focus', () => {
' childrenAllowed --> maybeHasChildren : add',
' maybeHasChildren --> childrenAllowed : removeAllChildren',
' maybeHasChildren --> maybeHasChildren : getFirstChild',
+ '%% lj-self-loops: edge3',
].join('\n'));
});
});
@@ -392,3 +393,79 @@ describe('state errors on several state sets', () => {
expect(getStateErrorFocuses([tiling, other])).toEqual([]);
});
});
+
+// Door from the multi-set state error issue: d.lock(); d.open(); where open() needs doorState closed and lockState unlocked
+function door(): LJStateMachine[] {
+ const open = { calledMethod: 'open', receiver: 'd' };
+ return [
+ {
+ className: 'Door', stateSet: 0, stateSetName: 'doorState', states: ['closed', 'opened'],
+ initialTransitions: [{ to: 'closed' }],
+ transitions: [
+ { from: 'closed', to: 'opened', label: 'open', fromCondition: 'lockState(this) == unlocked' },
+ { from: 'closed', to: 'closed', label: 'lock', fromCondition: 'lockState(this) != locked' },
+ ],
+ errorContext: { ...open, possibleStates: ['closed'], acceptedStates: ['closed'], validStates: [], invalidStates: [] },
+ },
+ {
+ className: 'Door', stateSet: 1, stateSetName: 'lockState', states: ['unlocked', 'locked'],
+ initialTransitions: [{ to: 'unlocked' }],
+ transitions: [
+ { from: 'unlocked', to: 'unlocked', label: 'open', fromCondition: 'doorState(this) == closed' },
+ { from: 'locked', to: 'unlocked', label: 'unlock' },
+ ],
+ errorContext: { ...open, possibleStates: ['locked'], acceptedStates: ['unlocked'], validStates: [], invalidStates: ['locked'] },
+ },
+ ];
+}
+
+describe('a state error on several state sets', () => {
+ const guard = (text: string) => `[${text}]`;
+ const unmet = (text: string) => `${text}`;
+
+ it('puts the state set in a wrong state first', () => {
+ const [doorState, lockState] = door();
+ expect(orderStateMachines([doorState, lockState])).toEqual([lockState, doorState]);
+ });
+
+ it('marks the call in the state set in a wrong state, and its unmet condition in the others', () => {
+ const sms = door();
+ const [doorState, lockState] = sms.map(sm => lines(createMermaidDiagram(sm, 'TB', true, false, sms)));
+
+ expect(lockState).toContain(`unlocked --> unlocked : ${guard('doorState(this) == closed')} ${calledMethod('open')}`);
+ expect(doorState).toContain(`closed --> opened : ${guard(unmet('lockState(this) == unlocked'))} open`);
+ expect(doorState).toContain(`closed --> closed : ${guard('lockState(this) != locked')} lock`);
+ // the unmet condition stays when conditions are collapsed
+ expect(lines(createMermaidDiagram(sms[0], 'TB', false, false, sms)))
+ .toEqual(expect.arrayContaining([`closed --> opened : ${guard(unmet('lockState(this) == unlocked'))} open`, 'closed --> closed : lock']));
+ // only the clauses the actual state does not meet are marked
+ sms[0].transitions[0].fromCondition = 'lockState(this) == unlocked && (size(this) > 0 || ready(this))';
+ expect(lines(createMermaidDiagram(sms[0], 'TB', true, false, sms)))
+ .toContain(`closed --> opened : ${guard(`${unmet('lockState(this) == unlocked')} && (size(this) > 0 || ready(this))`)} open`);
+ // without a condition on the state set in a wrong state, the call stays marked
+ sms[0].transitions[0].fromCondition = 'size(this) > 0';
+ expect(lines(createMermaidDiagram(sms[0], 'TB', false, false, sms))).toContain(`closed --> opened : ${calledMethod('open')}`);
+ });
+
+ it('draws a transition that keeps its state as a round loop', async () => {
+ document.body.innerHTML = '
';
+ const pre = document.querySelector('.mermaid')!;
+ pre.textContent = createMermaidDiagram(door()[1], 'TB');
+ // ELK's curl on the left side of the state, from its top to its bottom
+ const points = [{ x: 100, y: 10 }, { x: 85, y: 15 }, { x: 100, y: 20 }];
+ const run = vi.fn(async () => {
+ pre.innerHTML = '';
+ Object.assign(pre.querySelector('path')!, {
+ getTotalLength: () => 2,
+ getPointAtLength: (at: number) => points[at],
+ });
+ });
+
+ await renderMermaidDiagram(document, { mermaid: { run } });
+
+ const loop = document.querySelector('path.lj-self-loop')!;
+ expect(loop.getAttribute('data-id')).toBe('edge1');
+ // as far out as the curl
+ expect(loop.getAttribute('d')).toBe('M100,10 C80,0 80,30 100,20');
+ });
+});
diff --git a/client/src/webview/diagram-focus.ts b/client/src/webview/diagram-focus.ts
index 624ce45..4b62d36 100644
--- a/client/src/webview/diagram-focus.ts
+++ b/client/src/webview/diagram-focus.ts
@@ -63,12 +63,33 @@ export function getRejectedCallText(call: Pick sm.errorContext), ...sms.filter(sm => !sm.errorContext)];
+ const rank = (sm: LJStateMachine) => hasWrongState(sm) ? 0 : sm.errorContext ? 1 : 2;
+ return [...sms].sort((a, b) => rank(a) - rank(b));
+}
+
+/**
+ * Whether the receiver may be in a state of this state set that does not allow the call
+ */
+function hasWrongState(sm: LJStateMachine): boolean {
+ return (getStateErrorFocus(sm)?.invalidStates.length ?? 0) > 0;
+}
+
+/**
+ * Gets the other state sets whose state rejects the call, when every possible state of this one allows it
+ * @param sm the state machine of this state set
+ * @param sms the state machines of the class, one per state set
+ * @returns the state machines in a wrong state, empty when this one may be in a wrong state or its state is unknown
+ */
+export function getRejectingStateSets(sm: LJStateMachine, sms: LJStateMachine[]): LJStateMachine[] {
+ const focus = getStateErrorFocus(sm);
+ if (!focus || focus.unknownState || focus.invalidStates.length > 0) return [];
+ return sms.filter(other => other !== sm && hasWrongState(other));
}
/**
diff --git a/client/src/webview/diagram.ts b/client/src/webview/diagram.ts
index 16e5a51..e3c61cd 100644
--- a/client/src/webview/diagram.ts
+++ b/client/src/webview/diagram.ts
@@ -1,7 +1,8 @@
import type { LJStateMachine } from "../types/fsm";
import { copyToClipboard } from "./clipboard";
-import { INVALID_STATE_ICON, VALID_STATE_ICON, getRelatedStates, getStateErrorFocus } from "./diagram-focus";
+import { INVALID_STATE_ICON, VALID_STATE_ICON, getRejectingStateSets, getRelatedStates, getStateErrorFocus } from "./diagram-focus";
import { placePopover, type Box, type Point } from "./popover-placement";
+import { getConditionParts, type StateCondition } from "./state-conditions";
// constants
const MIN_ZOOM = 0.2;
@@ -11,6 +12,8 @@ const SCROLL_ZOOM_IN_FACTOR = 1.05;
const SCROLL_ZOOM_OUT_FACTOR = 0.95;
// Mermaid comment listing the transitions to dim once rendered
const DIMMED_TRANSITIONS_COMMENT = '%% lj-dimmed-transitions:';
+// Mermaid comment listing the transitions that keep their state, drawn as round loops once rendered
+const SELF_LOOPS_COMMENT = '%% lj-self-loops:';
// a focused diagram is shrunk to fit the panel, but never below this scale so that its labels stay readable
const MIN_FOCUS_SCALE = 0.6;
const FOCUS_BOTTOM_MARGIN = 16;
@@ -34,6 +37,7 @@ let startY = 0;
* @param orientation
* @param showConditions
* @param focusOnError whether to dim the states unrelated to the selected state error and collapse the unrelated methods
+ * @param stateSets the state machines of every state set of the class, to write conditions on them as their states
* @returns Mermaid diagram string
*/
export function createMermaidDiagram(
@@ -41,20 +45,26 @@ export function createMermaidDiagram(
orientation: "LR" | "TB",
showConditions = false,
focusOnError = false,
+ stateSets: LJStateMachine[] = [],
): string {
if (!sm) return '';
const lines: string[] = [];
const errorFocus = getStateErrorFocus(sm);
+ // when a transition of the call needs a state of another state set whose state rejects it,
+ // it is drawn as usual and that condition is marked instead
+ const rejectingStateSets = getRejectingStateSets(sm, stateSets);
const relatedStates = focusOnError && errorFocus ? getRelatedStates(errorFocus) : [];
const isDimmed = (state: string) => relatedStates.length > 0 && !relatedStates.includes(state);
// focusing collapses the dimmed methods of each label, so the diagram is laid out again around the error
const collapse = relatedStates.length > 0;
// Mermaid numbers the rendered transitions in the order they are declared
const dimmedTransitions: string[] = [];
+ const selfLoops: string[] = [];
let transitionCount = 0;
- const addTransition = (line: string, dimmed: boolean) => {
+ const addTransition = (line: string, dimmed: boolean, selfLoop = false) => {
if (dimmed) dimmedTransitions.push(`edge${transitionCount}`);
+ if (selfLoop) selfLoops.push(`edge${transitionCount}`);
transitionCount++;
lines.push(line);
};
@@ -106,12 +116,13 @@ export function createMermaidDiagram(
const isCalledMethod = transition.label === sm.errorContext?.calledMethod;
// in focus mode, only the failed method stays bright
const dimmed = collapse && !isCalledMethod;
+ const unmet = hasUnmetCondition(transition, sm, stateSets, rejectingStateSets);
const label = getTransitionLabel(
transition.label,
- transition.fromCondition,
+ getFromConditionLabel(transition.fromCondition, stateSets, unmet ? rejectingStateSets : null, showConditions),
transition.toCondition,
showConditions,
- isCalledMethod,
+ isCalledMethod && !unmet,
);
if (!transitionMap.has(key)) transitionMap.set(key, { bright: [], dimmed: [] });
const group = transitionMap.get(key)!;
@@ -124,8 +135,9 @@ export function createMermaidDiagram(
// add transitions, dimming those whose methods are all dimmed
transitionMap.forEach(({ bright, dimmed }, key) => {
const [from, to] = key.split('|');
- addTransition(` ${from} --> ${to} : ${getCollapsedLabel(bright, dimmed, 'methods')}`, bright.length === 0);
+ addTransition(` ${from} --> ${to} : ${getCollapsedLabel(bright, dimmed, 'methods')}`, bright.length === 0, from === to);
});
+ if (selfLoops.length > 0) lines.push(`${SELF_LOOPS_COMMENT} ${selfLoops.join(' ')}`);
if (!errorFocus) {
const highlightedStates = sm.errorContext?.possibleStates ?? [];
@@ -169,23 +181,61 @@ function getCollapsedLabel(bright: string[], dimmed: CollapsibleLabel[], noun: '
return [...bright, `${count}${methods}`].join(' ');
}
+/**
+ * Gets the label of a transition: the condition it needs before its method, and the one it sets after,
+ * e.g. [lockState(this) == unlocked] open [doorState(this) == opened]
+ * @param fromCondition the condition before, from getFromConditionLabel
+ * @param isFailedCall whether to mark the method as the rejected call
+ */
function getTransitionLabel(
label: string,
- fromCondition?: string | null,
+ fromCondition: string | null,
toCondition?: string | null,
showConditions = false,
- isCalledMethod = false,
+ isFailedCall = false,
): string {
const escapedLabel = escapeMermaidLabel(label);
- const methodLabel = isCalledMethod
+ const methodLabel = isFailedCall
? `${escapedLabel}`
: escapedLabel;
- if (!showConditions) return methodLabel;
return [
- getConditionLabel('pre', fromCondition),
+ fromCondition,
methodLabel,
- getConditionLabel('post', toCondition)
- ].filter(Boolean).join(' ');
+ showConditions ? getConditionLabel('post', toCondition) : '',
+ ].filter(Boolean).join(' ');
+}
+
+/**
+ * Whether a transition of the rejected call, from a possible state, needs a state of a state set whose state rejects it
+ */
+function hasUnmetCondition(transition: LJStateMachine['transitions'][number], sm: LJStateMachine,
+ stateSets: LJStateMachine[], rejectingStateSets: LJStateMachine[]): boolean {
+ if (rejectingStateSets.length === 0 || !transition.fromCondition || transition.label !== sm.errorContext?.calledMethod
+ || !sm.errorContext.possibleStates.includes(transition.from)) return false;
+ return getConditionParts(transition.fromCondition, stateSets)
+ .some(({ stateCondition }) => !!stateCondition && isUnmetCondition(stateCondition, rejectingStateSets));
+}
+
+// a condition on a state set whose state rejects the call, which some of its possible states do not meet
+function isUnmetCondition({ stateSet, states }: StateCondition, rejectingStateSets: LJStateMachine[]): boolean {
+ return rejectingStateSets.includes(stateSet) && !!stateSet.errorContext?.possibleStates.some(state => !states.includes(state));
+}
+
+/**
+ * Gets the label of the condition a transition needs before, as written in the spec, e.g. [lockState(this) == unlocked]
+ * @param rejectingStateSets the state sets whose state rejects the call, to mark their conditions as unmet,
+ * or null when the transition does not need their state
+ * @returns the label, null when there is no condition or conditions are hidden, an unmet one is always shown
+ */
+function getFromConditionLabel(condition: string | null | undefined, stateSets: LJStateMachine[],
+ rejectingStateSets: LJStateMachine[] | null, showConditions: boolean): string | null {
+ if (!condition || (!showConditions && !rejectingStateSets)) return null;
+ const parts = getConditionParts(condition, stateSets).map(({ text, stateCondition }) => {
+ const escaped = escapeMermaidLabel(text);
+ const unmet = rejectingStateSets && stateCondition && isUnmetCondition(stateCondition, rejectingStateSets);
+ return unmet ? `${escaped}` : escaped;
+ });
+ return getConditionLabel('pre', parts.join(escapeMermaidLabel(' && ')), false);
}
function getInitialTransitionLabel(constructorSignature?: string | null, toCondition?: string | null,
@@ -193,13 +243,13 @@ function getInitialTransitionLabel(constructorSignature?: string | null, toCondi
return [
constructorSignature ? escapeMermaidLabel(constructorSignature) : '',
showConditions ? getConditionLabel('post', toCondition) : '',
- ].filter(Boolean).join(' ');
+ ].filter(Boolean).join(' ');
}
-function getConditionLabel(kind: 'pre' | 'post', cond?: string | null): string {
+// a condition in brackets, escaped unless it is already
+function getConditionLabel(kind: 'pre' | 'post', cond?: string | null, escape = true): string {
if (!cond) return '';
-
- return `${escapeMermaidLabel(cond)}`;
+ return `[${escape ? escapeMermaidLabel(cond) : cond}]`;
}
function escapeMermaidLabel(label: string): string {
@@ -220,10 +270,12 @@ export async function renderMermaidDiagram(document: any, window: any) {
try {
const dimmedTransitions = Array.from(mermaidElements, (element: any) => getListedTransitions(element.textContent, DIMMED_TRANSITIONS_COMMENT));
+ const selfLoops = Array.from(mermaidElements, (element: any) => getListedTransitions(element.textContent, SELF_LOOPS_COMMENT));
await mermaid.run({ nodes: mermaidElements });
highlightErrorTransitions(document);
mermaidElements.forEach((element: any, index: number) => {
markTransitions(element, dimmedTransitions[index], 'lj-dimmed');
+ roundSelfLoops(element, selfLoops[index]);
});
fitFocusedDiagrams(document, window);
registerCollapsedLabels(document);
@@ -262,6 +314,38 @@ function markTransitions(element: any, edgeIds: string[], className: string) {
});
}
+// the layout draws a transition that keeps its state as a flat curl no bigger than its arrowhead,
+// so it is drawn again as a round loop between the same ends, as far out on the same side, within the laid out bounds
+function roundSelfLoops(element: any, edgeIds: string[]) {
+ edgeIds.forEach(edgeId => {
+ const path = element.querySelector(`path.transition[data-id="${edgeId}"]`);
+ if (typeof path?.getTotalLength !== 'function') return;
+ const length = path.getTotalLength();
+ const [start, middle, end] = [0, length / 2, length].map(at => path.getPointAtLength(at));
+ path.setAttribute('d', getSelfLoopPath(start, middle, end));
+ path.classList.add('lj-self-loop');
+ });
+}
+
+function getSelfLoopPath(start: Point, middle: Point, end: Point): string {
+ const depth = { x: middle.x - (start.x + end.x) / 2, y: middle.y - (start.y + end.y) / 2 };
+ const out = unit(depth);
+ const along = unit({ x: end.x - start.x, y: end.y - start.y });
+ // a cubic curve reaches 3/4 of the way to its control points
+ const reach = Math.hypot(depth.x, depth.y) * 4 / 3;
+ const control = (point: Point, sign: number) => ({
+ x: point.x + reach * (out.x + sign * along.x / 2),
+ y: point.y + reach * (out.y + sign * along.y / 2),
+ });
+ const [first, second] = [control(start, -1), control(end, 1)];
+ return `M${start.x},${start.y} C${first.x},${first.y} ${second.x},${second.y} ${end.x},${end.y}`;
+}
+
+function unit({ x, y }: Point): Point {
+ const length = Math.hypot(x, y) || 1;
+ return { x: x / length, y: y / length };
+}
+
// shrinks the focused diagrams with a state error so that they fit below the summary, keeping them readable
function fitFocusedDiagrams(document: any, window: any) {
const viewport = document.querySelector('.diagram-viewport.diagram-focused') as HTMLElement | null;
diff --git a/client/src/webview/script.spec.ts b/client/src/webview/script.spec.ts
index 270f4c3..fd45719 100644
--- a/client/src/webview/script.spec.ts
+++ b/client/src/webview/script.spec.ts
@@ -286,7 +286,7 @@ describe('webview script', () => {
click('[data-tab="fsm"]');
expect(root.querySelector('#diagram-conditions-btn')?.getAttribute('aria-pressed')).toBe('true');
- expect(Array.from(root.querySelectorAll('.mermaid .state-cond'), node => node.textContent)).toEqual(['size(counter) > 0', 'size(counter) == 0']);
+ expect(Array.from(root.querySelectorAll('.mermaid .state-cond'), node => node.textContent)).toEqual(['[size(counter) > 0]', '[size(counter) == 0]']);
click('#diagram-conditions-btn');
expect(root.querySelector('.mermaid .state-cond')).toBeNull();
diff --git a/client/src/webview/script.ts b/client/src/webview/script.ts
index 2acc952..bce2a0d 100644
--- a/client/src/webview/script.ts
+++ b/client/src/webview/script.ts
@@ -435,10 +435,10 @@ export function getScript(vscode: VSCodeApi, document: Document, window: Window)
const displayedStateMachines = orderStateMachines(shownError?.stateMachines ?? stateMachines);
const focusOnError = focusDiagramOnError && !!shownError;
const diagrams = displayedStateMachines.map(sm =>
- createMermaidDiagram(sm, diagramOrientation, showDiagramConditions, focusOnError));
+ createMermaidDiagram(sm, diagramOrientation, showDiagramConditions, focusOnError, displayedStateMachines));
// copy the diagrams without the error overlay
currentDiagram = displayedStateMachines
- .map(sm => createMermaidDiagram({ ...sm, errorContext: null }, diagramOrientation, showDiagramConditions))
+ .map(sm => createMermaidDiagram({ ...sm, errorContext: null }, diagramOrientation, showDiagramConditions, false, displayedStateMachines))
.join('\n\n');
renderStateMachineView(root, displayedStateMachines, diagrams, diagramOrientation, showDiagramConditions, focusOnError,
{ index: shownIndex, count: stateErrors.length, line: getStateErrorLine(shownError) }, badges);
diff --git a/client/src/webview/state-conditions.ts b/client/src/webview/state-conditions.ts
new file mode 100644
index 0000000..221f9b8
--- /dev/null
+++ b/client/src/webview/state-conditions.ts
@@ -0,0 +1,75 @@
+import type { LJStateMachine } from "../types/fsm";
+
+/**
+ * A condition of a transition on the state of a state set, as the states it allows
+ */
+export type StateCondition = {
+ stateSet: LJStateMachine;
+ states: string[];
+};
+
+// lockState(this) == unlocked, lockState(this) != locked
+const STATE_SET_FIRST = /^(\w+)\(this\)\s*(==|!=)\s*(\w+)$/;
+// unlocked == lockState(this)
+const STATE_FIRST = /^(\w+)\s*(==|!=)\s*(\w+)\(this\)$/;
+// unlocked(this), for a state set without a name
+const STATE_ONLY = /^(\w+)\(this\)$/;
+
+/**
+ * A part of a transition's condition joined by &&, as written in the spec, with the states it needs if it is about a state
+ */
+export type ConditionPart = {
+ text: string;
+ stateCondition: StateCondition | null;
+};
+
+/**
+ * Splits a transition's condition into its parts joined by a top-level &&, reading each one as the states it needs
+ * @param condition the condition as written in the spec, e.g. lockState(this) != locked && size(this) > 0
+ * @param sms the state machines of the class, one per state set
+ * @returns the parts, a part with an || is not read as states
+ */
+export function getConditionParts(condition: string, sms: LJStateMachine[]): ConditionPart[] {
+ return splitTopLevelAnd(condition).map(text => ({
+ text,
+ stateCondition: text.includes('||') ? null : parseStateCondition(unwrap(text), sms),
+ }));
+}
+
+// splits on the && outside parentheses, so a parenthesised || stays in one part
+function splitTopLevelAnd(condition: string): string[] {
+ const parts: string[] = [];
+ let depth = 0;
+ let start = 0;
+ for (let i = 0; i < condition.length; i++) {
+ if (condition[i] === '(') depth++;
+ else if (condition[i] === ')') depth--;
+ else if (depth === 0 && condition.startsWith('&&', i)) {
+ parts.push(condition.slice(start, i).trim());
+ start = i + 2;
+ }
+ }
+ return [...parts, condition.slice(start).trim()];
+}
+
+function parseStateCondition(text: string, sms: LJStateMachine[]): StateCondition | null {
+ const stateSetFirst = text.match(STATE_SET_FIRST);
+ const stateFirst = text.match(STATE_FIRST);
+ if (stateSetFirst) return getComparedStates(stateSetFirst[1], stateSetFirst[2], stateSetFirst[3], sms);
+ if (stateFirst) return getComparedStates(stateFirst[3], stateFirst[2], stateFirst[1], sms);
+
+ const state = text.match(STATE_ONLY)?.[1];
+ const withState = state ? sms.filter(sm => sm.states.includes(state)) : [];
+ return state && withState.length === 1 ? { stateSet: withState[0], states: [state] } : null;
+}
+
+// a != on a state set allows its other states
+function getComparedStates(name: string, operator: string, state: string, sms: LJStateMachine[]): StateCondition | null {
+ const stateSet = sms.find(sm => sm.stateSetName === name && sm.states.includes(state));
+ if (!stateSet) return null;
+ return { stateSet, states: operator === '==' ? [state] : stateSet.states.filter(other => other !== state) };
+}
+
+function unwrap(text: string): string {
+ return text.replace(/^\((.*)\)$/, '$1').trim();
+}
diff --git a/client/src/webview/styles.ts b/client/src/webview/styles.ts
index e682ed1..ce777a9 100644
--- a/client/src/webview/styles.ts
+++ b/client/src/webview/styles.ts
@@ -1080,6 +1080,12 @@ export function getStyles(): string {
color: var(--vscode-foreground) !important;
background: var(--vscode-editor-background) !important;
}
+ /* a label reads as [condition before] method [condition after], so Mermaid must not wrap it */
+ .diagram-container .mermaid .edgeLabel div {
+ white-space: nowrap !important;
+ width: auto !important;
+ max-width: none !important;
+ }
.diagram-container .mermaid .edgeLabel .state-cond {
color: var(--vscode-descriptionForeground) !important;
display: inline-block !important;
@@ -1110,9 +1116,13 @@ export function getStyles(): string {
.diagram-container .mermaid svg .ljStateErrorActual .label-container {
stroke: var(--vscode-errorForeground) !important;
}
- .diagram-container .mermaid .edgeLabel .lj-state-error-method {
+ .diagram-container .mermaid .edgeLabel .lj-state-error-method,
+ .diagram-container .mermaid .edgeLabel .lj-unmet-condition {
color: var(--vscode-errorForeground) !important;
}
+ .diagram-container .mermaid svg path.lj-self-loop {
+ stroke-width: 1.5px !important;
+ }
.diagram-container .mermaid svg path.lj-state-error-transition {
stroke: var(--vscode-errorForeground) !important;
}
diff --git a/client/src/webview/views/fsm/fsm.spec.ts b/client/src/webview/views/fsm/fsm.spec.ts
index 8ab4212..1d4110c 100644
--- a/client/src/webview/views/fsm/fsm.spec.ts
+++ b/client/src/webview/views/fsm/fsm.spec.ts
@@ -370,6 +370,29 @@ describe('state machine view', () => {
.toEqual(['\u2715 Actual state, not expected', '\u2713 Actual state, expected', 'Expected state']);
});
+ it('names the state sets in the summary, without those in which every state allows the call', () => {
+ // d.lock(); d.open(); where open() needs doorState closed and lockState unlocked, and any mode
+ const open = { calledMethod: 'open', receiver: 'd' };
+ const sms = [
+ machine({ stateSet: 1, stateSetName: 'lockState', states: ['unlocked', 'locked'],
+ errorContext: { ...open, possibleStates: ['locked'], acceptedStates: ['unlocked'], validStates: [], invalidStates: ['locked'] } }),
+ machine({ stateSet: 0, stateSetName: 'doorState', states: ['closed', 'opened'],
+ errorContext: { ...open, possibleStates: ['closed'], acceptedStates: ['closed'], validStates: [], invalidStates: [] } }),
+ machine({ stateSet: 2, stateSetName: 'mode', states: ['quiet', 'loud'],
+ errorContext: { ...open, possibleStates: ['quiet'], acceptedStates: ['quiet', 'loud'], validStates: [], invalidStates: [] } }),
+ ];
+ renderStateMachineView(document.body, sms, ['lock diagram', 'door diagram', 'mode diagram'], 'TB', false, true);
+
+ expect(document.querySelector('.diagram-error-text')?.textContent)
+ .toBe('Invalid method call d.open() \u00B7 expected doorState closed, lockState unlocked \u00B7 actual lockState locked');
+
+ // with no set in a wrong state, the call is rejected by the combination of states, so every set is named
+ sms[0].errorContext = { ...open, possibleStates: ['locked'], acceptedStates: ['unlocked', 'locked'], validStates: [], invalidStates: [] };
+ renderStateMachineView(document.body, sms, ['lock diagram', 'door diagram', 'mode diagram'], 'TB', false, true);
+ expect(document.querySelector('.diagram-error-text')?.textContent)
+ .toBe('Invalid method call d.open() \u00B7 expected doorState closed, lockState unlocked or locked, mode quiet or loud \u00B7 actual doorState closed, lockState locked, mode quiet');
+ });
+
it('fits the diagram to the panel only when focusing on the error', () => {
const sm = { ...tiling, errorContext: { calledMethod: 'write', receiver: 'param', possibleStates: ['startTiling'], acceptedStates: ['tilingSet'], validStates: [], invalidStates: ['startTiling'] } };
diff --git a/client/src/webview/views/fsm/fsm.ts b/client/src/webview/views/fsm/fsm.ts
index f70086b..1f06454 100644
--- a/client/src/webview/views/fsm/fsm.ts
+++ b/client/src/webview/views/fsm/fsm.ts
@@ -1,5 +1,5 @@
import type { LJStateMachine } from "../../../types/fsm";
-import { INVALID_STATE_ICON, VALID_STATE_ICON, getRejectedCallText, getStateErrorFocuses, type StateErrorFocus } from "../../diagram-focus";
+import { INVALID_STATE_ICON, VALID_STATE_ICON, getRejectedCallText, getStateErrorFocus, getStateErrorFocuses, type StateErrorFocus } from "../../diagram-focus";
import { renderCodiconButton } from "../../icons";
import { escapeHtml } from "../../utils";
import { renderMainHeader, type NavBadges } from "../sections";
@@ -57,7 +57,7 @@ function renderStateMachineViewHtml(sms: LJStateMachine[], diagrams: string[], o
${errorFocuses.length > 0
- ? renderStateErrorSummary(getStateErrorSummary(errorFocuses), stateErrors) + renderStateErrorLegend(errorFocuses, focusOnError)
+ ? renderStateErrorSummary(getStateErrorSummary(sms), stateErrors) + renderStateErrorLegend(errorFocuses, focusOnError)
: isStateErrorShown(stateErrors) ? renderStateErrorSummary(getUnmarkedStateErrorSummary(sms, stateErrors), stateErrors) : ''}
${showConditions && hasConditionExpansions ? /*html*/`
@@ -96,17 +96,44 @@ function isStateErrorShown(stateErrors: StateErrorNavigation): boolean {
return stateErrors.index >= 0 || stateErrors.line !== undefined;
}
+// the state error on one state set, with its state machine
+type StateSetError = { sm: LJStateMachine; focus: StateErrorFocus };
+
// summarises the error of every state set it involves as a compiler would: the invalid call, then the expected and actual states
-function getStateErrorSummary(focuses: StateErrorFocus[]): string {
- const call = getRejectedCallText(focuses[0]);
+function getStateErrorSummary(sms: LJStateMachine[]): string {
+ // in declaration order, as the call's spec names them
+ const errors = sms.flatMap(sm => {
+ const focus = getStateErrorFocus(sm);
+ return focus ? [{ sm, focus }] : [];
+ }).sort((a, b) => (a.sm.stateSet ?? 0) - (b.sm.stateSet ?? 0));
+ const call = getRejectedCallText(errors[0].focus);
+ // a state set in which every state allows the call is left out when another set is in a wrong state,
+ // otherwise the call is rejected by a combination of states, which every set is part of
+ const hasWrongState = errors.some(({ focus }) => focus.invalidStates.length > 0);
+ const constraining = errors.filter(({ sm, focus }) => focus.acceptedStates.length < sm.states.length);
+ const shownErrors = hasWrongState && constraining.length > 0 ? constraining : errors;
+ const knownErrors = shownErrors.filter(({ focus }) => !focus.unknownState);
+ if (errors.every(({ sm }) => sm.stateSetName)) {
+ return `${renderInvalidCall(call)} \u00B7 expected ${renderNamedStateSets(shownErrors, focus => focus.acceptedStates)} \u00B7 actual ${getNamedActualStates(knownErrors)}`;
+ }
// the call needs an expected state of each state set
- const expected = renderStateSets(focuses.map(focus => focus.acceptedStates));
- const actual = focuses.every(focus => focus.unknownState)
- ? 'unknown'
- : renderStateSets(focuses.filter(focus => !focus.unknownState).map(focus => focus.possibleStates));
+ const expected = renderStateSets(shownErrors.map(({ focus }) => focus.acceptedStates));
+ const actual = knownErrors.length === 0 ? 'unknown' : renderStateSets(knownErrors.map(({ focus }) => focus.possibleStates));
return `${renderInvalidCall(call)} \u00B7 expected state ${expected} \u00B7 actual state ${actual}`;
}
+// the actual states of the named state sets in a wrong state, or of all whose state is known if none is
+function getNamedActualStates(knownErrors: StateSetError[]): string {
+ if (knownErrors.length === 0) return 'state unknown';
+ const wrong = knownErrors.filter(({ focus }) => focus.invalidStates.length > 0);
+ return renderNamedStateSets(wrong.length > 0 ? wrong : knownErrors, focus => focus.possibleStates);
+}
+
+// the states of each named state set after its name, e.g. doorState closed, lockState unlocked
+function renderNamedStateSets(errors: StateSetError[], states: (focus: StateErrorFocus) => string[]): string {
+ return errors.map(({ sm, focus }) => `${escapeHtml(sm.stateSetName ?? '')} ${renderStates(states(focus))}`).join(', ');
+}
+
// the shown error cannot be marked on the diagrams when the verifier could not tell which states allow the call
function getUnmarkedStateErrorSummary(sms: LJStateMachine[], stateErrors: StateErrorNavigation): string {
const context = sms.find(sm => sm.errorContext)?.errorContext;