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;