From 056675a494fbc4c5bafde15e043dde32f2250375 Mon Sep 17 00:00:00 2001 From: Catarina Gamboa Date: Tue, 10 Nov 2026 01:34:09 +0000 Subject: [PATCH 1/9] Show a multi-set state error in the state set that is wrong MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In the State Machine view, when the call is rejected because of the state of another state set: - the call is red only in the diagram of the state set in a wrong state; in the others it is drawn as usual and its unmet condition is red, also with conditions collapsed - the diagram of the state set in a wrong state comes first, so focus mode fits it - a transition that keeps its state is drawn as a round loop instead of a curl as small as its arrowhead - a condition on other state sets is written as their states after the method, e.g. open [lockState: unlocked], with != on a two-state set as its other state - the summary line names the state sets (expected doorState closed, lockState unlocked ยท actual lockState locked) and leaves out those in which every state allows the call; unnamed state sets keep the current wording Co-Authored-By: Claude Opus 5.5 --- client/src/webview/diagram-focus.spec.ts | 70 +++++++++++++++ client/src/webview/diagram-focus.ts | 27 +++++- client/src/webview/diagram.ts | 107 ++++++++++++++++++++--- client/src/webview/script.ts | 4 +- client/src/webview/state-conditions.ts | 50 +++++++++++ client/src/webview/styles.ts | 6 +- client/src/webview/views/fsm/fsm.spec.ts | 17 ++++ client/src/webview/views/fsm/fsm.ts | 41 +++++++-- 8 files changed, 294 insertions(+), 28 deletions(-) create mode 100644 client/src/webview/state-conditions.ts diff --git a/client/src/webview/diagram-focus.spec.ts b/client/src/webview/diagram-focus.spec.ts index 7f9a2a0..53bbdaf 100644 --- a/client/src/webview/diagram-focus.spec.ts +++ b/client/src/webview/diagram-focus.spec.ts @@ -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,72 @@ 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 : ${calledMethod('open')} ${guard('doorState: closed')}`); + expect(doorState).toContain(`closed --> opened : open ${guard(unmet('lockState: unlocked'))}`); + // a != on a state set with two states is its other state + expect(doorState).toContain(`closed --> closed : lock ${guard('lockState: unlocked')}`); + // the unmet condition stays when conditions are collapsed + expect(lines(createMermaidDiagram(sms[0], 'TB', false, false, sms))) + .toEqual(expect.arrayContaining([`closed --> opened : open ${guard(unmet('lockState: unlocked'))}`, 'closed --> closed : lock'])); + }); + + 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: 80, 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'); + expect(loop.getAttribute('d')).toBe('M100,10 C68,-6 68,36 100,20'); + }); +}); diff --git a/client/src/webview/diagram-focus.ts b/client/src/webview/diagram-focus.ts index 624ce45..0faf348 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 + */ +export 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..8df3b7b 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 { parseStateConditions, type StateCondition } from "./state-conditions"; // constants const MIN_ZOOM = 0.2; @@ -11,6 +12,10 @@ 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:'; +// how far the control points of a round loop are from its ends +const SELF_LOOP_RADIUS = 32; // 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 +39,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 +47,25 @@ export function createMermaidDiagram( orientation: "LR" | "TB", showConditions = false, focusOnError = false, + stateSets: LJStateMachine[] = [], ): string { if (!sm) return ''; const lines: string[] = []; const errorFocus = getStateErrorFocus(sm); + // when the state of another state set rejects the call, the call is drawn as usual here and its 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 +117,14 @@ export function createMermaidDiagram( const isCalledMethod = transition.label === sm.errorContext?.calledMethod; // in focus mode, only the failed method stays bright const dimmed = collapse && !isCalledMethod; + const rejectedByOthers = isCalledMethod && rejectingStateSets.length > 0; + const unmet = rejectedByOthers && !!errorFocus?.possibleStates.includes(transition.from); const label = getTransitionLabel( transition.label, - transition.fromCondition, + getFromConditionLabel(transition.fromCondition, stateSets, unmet ? rejectingStateSets : null, showConditions), transition.toCondition, showConditions, - isCalledMethod, + isCalledMethod && !rejectedByOthers, ); if (!transitionMap.has(key)) transitionMap.set(key, { bright: [], dimmed: [] }); const group = transitionMap.get(key)!; @@ -124,8 +137,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,25 +183,59 @@ function getCollapsedLabel(bright: string[], dimmed: CollapsibleLabel[], noun: ' return [...bright, `${count}${methods}`].join('
'); } +/** + * Gets the label of a transition: its method with the condition it needs before, and the one it sets after + * @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: FromConditionLabel | 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; + // a condition written as states reads as a guard of the method, on its line + const guard = fromCondition?.isGuard ? fromCondition.label : ''; return [ - getConditionLabel('pre', fromCondition), - methodLabel, - getConditionLabel('post', toCondition) + guard ? '' : fromCondition?.label, + guard ? `${methodLabel} ${guard}` : methodLabel, + showConditions ? getConditionLabel('post', toCondition) : '', ].filter(Boolean).join('
'); } +/** + * Gets the label of the condition a transition needs of the other state sets, + * written as their states when it is only about them, e.g. [lockState: unlocked] + * @param rejectingStateSets the state sets whose state rejects the call, to mark their conditions as unmet, + * or null when the transition is not the rejected call + * @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): FromConditionLabel | null { + if (!condition || (!showConditions && !rejectingStateSets)) return null; + const conditions = parseStateConditions(condition, stateSets); + if (!conditions) return { label: getConditionLabel('pre', condition, !!rejectingStateSets), isGuard: false }; + const guards = conditions.map(stateCondition => { + const text = escapeMermaidLabel(getStateConditionText(stateCondition)); + return rejectingStateSets?.includes(stateCondition.stateSet) ? `${text}` : text; + }); + return { label: `[${guards.join(', ')}]`, isGuard: true }; +} + +// the label of a transition's condition before, and whether it is written as the states it needs +type FromConditionLabel = { label: string; isGuard: boolean }; + +// the states a condition allows, after the name of their state set if it has one, e.g. lockState: unlocked +function getStateConditionText({ stateSet, states }: StateCondition): string { + const allowed = states.join(' or '); + return stateSet.stateSetName ? `${stateSet.stateSetName}: ${allowed}` : allowed; +} + function getInitialTransitionLabel(constructorSignature?: string | null, toCondition?: string | null, showConditions = false): string { return [ @@ -196,10 +244,10 @@ function getInitialTransitionLabel(constructorSignature?: string | null, toCondi ].filter(Boolean).join('
'); } -function getConditionLabel(kind: 'pre' | 'post', cond?: string | null): string { +function getConditionLabel(kind: 'pre' | 'post', cond?: string | null, unmet = false): string { if (!cond) return ''; - return `${escapeMermaidLabel(cond)}`; + return `${escapeMermaidLabel(cond)}`; } function escapeMermaidLabel(label: string): string { @@ -220,10 +268,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 +312,35 @@ function markTransitions(element: any, edgeIds: string[], className: string) { }); } +// the layout draws a transition that keeps its state as a curl no bigger than its arrowhead, +// so it is drawn again as a round loop between the same ends, bulging out on the same side +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 out = unit({ x: middle.x - (start.x + end.x) / 2, y: middle.y - (start.y + end.y) / 2 }); + const along = unit({ x: end.x - start.x, y: end.y - start.y }); + const control = (point: Point, sign: number) => ({ + x: point.x + SELF_LOOP_RADIUS * (out.x + sign * along.x / 2), + y: point.y + SELF_LOOP_RADIUS * (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.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..b8a5905 --- /dev/null +++ b/client/src/webview/state-conditions.ts @@ -0,0 +1,50 @@ +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\)$/; + +/** + * Reads a transition's condition as the states it needs of the class's state sets + * @param condition the condition as written in the spec, e.g. lockState(this) != locked && mode(this) == fast + * @param sms the state machines of the class, one per state set + * @returns the condition on each state set, or null when a part of it is not about a state + */ +export function parseStateConditions(condition: string, sms: LJStateMachine[]): StateCondition[] | null { + if (condition.includes('||')) return null; + const conditions = condition.split('&&').map(part => parseStateCondition(unwrap(part.trim()), sms)); + return conditions.every(Boolean) ? conditions as StateCondition[] : null; +} + +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..a181633 100644 --- a/client/src/webview/styles.ts +++ b/client/src/webview/styles.ts @@ -1110,9 +1110,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..ae20b92 100644 --- a/client/src/webview/views/fsm/fsm.spec.ts +++ b/client/src/webview/views/fsm/fsm.spec.ts @@ -370,6 +370,23 @@ 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'); + }); + 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..a80409e 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,42 @@ 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 does not constrain it + const constraining = errors.filter(({ sm, focus }) => focus.acceptedStates.length < sm.states.length); + const expectedErrors = constraining.length > 0 ? constraining : errors; + const knownErrors = errors.filter(({ focus }) => !focus.unknownState); + if (errors.every(({ sm }) => sm.stateSetName)) { + return `${renderInvalidCall(call)} \u00B7 expected ${renderNamedStateSets(expectedErrors, 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(expectedErrors.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; From a00337ddaca4ae6111598d614870c1d1a5ac3a40 Mon Sep 17 00:00:00 2001 From: Catarina Gamboa Date: Tue, 10 Nov 2026 01:39:34 +0000 Subject: [PATCH 2/9] Keep the rejected call marked unless its condition names the state set in a wrong state Co-Authored-By: Claude Opus 5.5 --- client/src/webview/diagram-focus.spec.ts | 3 +++ client/src/webview/diagram.ts | 31 +++++++++++++++++------- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/client/src/webview/diagram-focus.spec.ts b/client/src/webview/diagram-focus.spec.ts index 53bbdaf..8e58dab 100644 --- a/client/src/webview/diagram-focus.spec.ts +++ b/client/src/webview/diagram-focus.spec.ts @@ -439,6 +439,9 @@ describe('a state error on several state sets', () => { // the unmet condition stays when conditions are collapsed expect(lines(createMermaidDiagram(sms[0], 'TB', false, false, sms))) .toEqual(expect.arrayContaining([`closed --> opened : open ${guard(unmet('lockState: unlocked'))}`, 'closed --> closed : lock'])); + // 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 () => { diff --git a/client/src/webview/diagram.ts b/client/src/webview/diagram.ts index 8df3b7b..20f0738 100644 --- a/client/src/webview/diagram.ts +++ b/client/src/webview/diagram.ts @@ -53,8 +53,12 @@ export function createMermaidDiagram( const lines: string[] = []; const errorFocus = getStateErrorFocus(sm); - // when the state of another state set rejects the call, the call is drawn as usual here and its condition is marked instead + // when the call's condition here names another state set whose state rejects it, + // the call is drawn as usual and that condition is marked instead const rejectingStateSets = getRejectingStateSets(sm, stateSets); + const isUnmet = (transition: LJStateMachine['transitions'][number]) => + hasUnmetCondition(transition, sm, stateSets, rejectingStateSets); + const hasUnmetCall = sm.transitions.some(isUnmet); 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 @@ -117,14 +121,12 @@ export function createMermaidDiagram( const isCalledMethod = transition.label === sm.errorContext?.calledMethod; // in focus mode, only the failed method stays bright const dimmed = collapse && !isCalledMethod; - const rejectedByOthers = isCalledMethod && rejectingStateSets.length > 0; - const unmet = rejectedByOthers && !!errorFocus?.possibleStates.includes(transition.from); const label = getTransitionLabel( transition.label, - getFromConditionLabel(transition.fromCondition, stateSets, unmet ? rejectingStateSets : null, showConditions), + getFromConditionLabel(transition.fromCondition, stateSets, isUnmet(transition) ? rejectingStateSets : null, showConditions), transition.toCondition, showConditions, - isCalledMethod && !rejectedByOthers, + isCalledMethod && !hasUnmetCall, ); if (!transitionMap.has(key)) transitionMap.set(key, { bright: [], dimmed: [] }); const group = transitionMap.get(key)!; @@ -208,18 +210,29 @@ function getTransitionLabel( ].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; + const conditions = parseStateConditions(transition.fromCondition, stateSets); + return !!conditions?.some(condition => rejectingStateSets.includes(condition.stateSet)); +} + /** * Gets the label of the condition a transition needs of the other state sets, * written as their states when it is only about them, e.g. [lockState: unlocked] * @param rejectingStateSets the state sets whose state rejects the call, to mark their conditions as unmet, - * or null when the transition is not the rejected call + * 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): FromConditionLabel | null { if (!condition || (!showConditions && !rejectingStateSets)) return null; const conditions = parseStateConditions(condition, stateSets); - if (!conditions) return { label: getConditionLabel('pre', condition, !!rejectingStateSets), isGuard: false }; + if (!conditions) return { label: getConditionLabel('pre', condition), isGuard: false }; const guards = conditions.map(stateCondition => { const text = escapeMermaidLabel(getStateConditionText(stateCondition)); return rejectingStateSets?.includes(stateCondition.stateSet) ? `${text}` : text; @@ -244,10 +257,10 @@ function getInitialTransitionLabel(constructorSignature?: string | null, toCondi ].filter(Boolean).join('
'); } -function getConditionLabel(kind: 'pre' | 'post', cond?: string | null, unmet = false): string { +function getConditionLabel(kind: 'pre' | 'post', cond?: string | null): string { if (!cond) return ''; - return `${escapeMermaidLabel(cond)}`; + return `${escapeMermaidLabel(cond)}`; } function escapeMermaidLabel(label: string): string { From b532387d1842bf25b2a128773ad16ef7e15bcd77 Mon Sep 17 00:00:00 2001 From: Catarina Gamboa Date: Tue, 10 Nov 2026 01:43:43 +0000 Subject: [PATCH 3/9] Mark only the clauses the actual state does not meet, and keep round loops as far out as the curl Co-Authored-By: Claude Opus 5.5 --- client/src/webview/diagram-focus.spec.ts | 10 ++++++++-- client/src/webview/diagram.ts | 24 +++++++++++++++--------- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/client/src/webview/diagram-focus.spec.ts b/client/src/webview/diagram-focus.spec.ts index 8e58dab..c263b63 100644 --- a/client/src/webview/diagram-focus.spec.ts +++ b/client/src/webview/diagram-focus.spec.ts @@ -439,6 +439,11 @@ describe('a state error on several state sets', () => { // the unmet condition stays when conditions are collapsed expect(lines(createMermaidDiagram(sms[0], 'TB', false, false, sms))) .toEqual(expect.arrayContaining([`closed --> opened : open ${guard(unmet('lockState: unlocked'))}`, 'closed --> closed : lock'])); + // only the clauses the actual state does not meet are marked + sms[0].transitions[0].fromCondition = 'lockState(this) != jammed && lockState(this) == unlocked'; + sms[1].states.push('jammed'); + expect(lines(createMermaidDiagram(sms[0], 'TB', true, false, sms))) + .toContain(`closed --> opened : open ${guard(`lockState: unlocked or locked, ${unmet('lockState: unlocked')}`)}`); // 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')}`); @@ -449,7 +454,7 @@ describe('a state error on several state sets', () => { 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: 80, y: 15 }, { x: 100, y: 20 }]; + 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')!, { @@ -462,6 +467,7 @@ describe('a state error on several state sets', () => { const loop = document.querySelector('path.lj-self-loop')!; expect(loop.getAttribute('data-id')).toBe('edge1'); - expect(loop.getAttribute('d')).toBe('M100,10 C68,-6 68,36 100,20'); + // 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.ts b/client/src/webview/diagram.ts index 20f0738..13079bb 100644 --- a/client/src/webview/diagram.ts +++ b/client/src/webview/diagram.ts @@ -14,8 +14,6 @@ const SCROLL_ZOOM_OUT_FACTOR = 0.95; 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:'; -// how far the control points of a round loop are from its ends -const SELF_LOOP_RADIUS = 32; // 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; @@ -218,7 +216,12 @@ function hasUnmetCondition(transition: LJStateMachine['transitions'][number], sm if (rejectingStateSets.length === 0 || !transition.fromCondition || transition.label !== sm.errorContext?.calledMethod || !sm.errorContext.possibleStates.includes(transition.from)) return false; const conditions = parseStateConditions(transition.fromCondition, stateSets); - return !!conditions?.some(condition => rejectingStateSets.includes(condition.stateSet)); + return !!conditions?.some(condition => isUnmetCondition(condition, 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)); } /** @@ -235,7 +238,7 @@ function getFromConditionLabel(condition: string | null | undefined, stateSets: if (!conditions) return { label: getConditionLabel('pre', condition), isGuard: false }; const guards = conditions.map(stateCondition => { const text = escapeMermaidLabel(getStateConditionText(stateCondition)); - return rejectingStateSets?.includes(stateCondition.stateSet) ? `${text}` : text; + return rejectingStateSets && isUnmetCondition(stateCondition, rejectingStateSets) ? `${text}` : text; }); return { label: `[${guards.join(', ')}]`, isGuard: true }; } @@ -325,8 +328,8 @@ function markTransitions(element: any, edgeIds: string[], className: string) { }); } -// the layout draws a transition that keeps its state as a curl no bigger than its arrowhead, -// so it is drawn again as a round loop between the same ends, bulging out on the same side +// 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}"]`); @@ -339,11 +342,14 @@ function roundSelfLoops(element: any, edgeIds: string[]) { } function getSelfLoopPath(start: Point, middle: Point, end: Point): string { - const out = unit({ x: middle.x - (start.x + end.x) / 2, y: middle.y - (start.y + end.y) / 2 }); + 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 + SELF_LOOP_RADIUS * (out.x + sign * along.x / 2), - y: point.y + SELF_LOOP_RADIUS * (out.y + sign * along.y / 2), + 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}`; From 5a5ec09b5cb8d0b4c7713b1cc1d9608a96a07100 Mon Sep 17 00:00:00 2001 From: Catarina Gamboa Date: Tue, 10 Nov 2026 01:50:14 +0000 Subject: [PATCH 4/9] Keep the rejected call marked on each transition without an unmet condition Co-Authored-By: Claude Opus 5.5 --- client/src/webview/diagram.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/client/src/webview/diagram.ts b/client/src/webview/diagram.ts index 13079bb..f72fbaa 100644 --- a/client/src/webview/diagram.ts +++ b/client/src/webview/diagram.ts @@ -51,12 +51,9 @@ export function createMermaidDiagram( const lines: string[] = []; const errorFocus = getStateErrorFocus(sm); - // when the call's condition here names another state set whose state rejects it, - // the call is drawn as usual and that condition is marked instead + // 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 isUnmet = (transition: LJStateMachine['transitions'][number]) => - hasUnmetCondition(transition, sm, stateSets, rejectingStateSets); - const hasUnmetCall = sm.transitions.some(isUnmet); 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 @@ -119,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, - getFromConditionLabel(transition.fromCondition, stateSets, isUnmet(transition) ? rejectingStateSets : null, showConditions), + getFromConditionLabel(transition.fromCondition, stateSets, unmet ? rejectingStateSets : null, showConditions), transition.toCondition, showConditions, - isCalledMethod && !hasUnmetCall, + isCalledMethod && !unmet, ); if (!transitionMap.has(key)) transitionMap.set(key, { bright: [], dimmed: [] }); const group = transitionMap.get(key)!; From 1d4d8931377fd09db9239e9b2a99248d08536875 Mon Sep 17 00:00:00 2001 From: Catarina Gamboa Date: Tue, 10 Nov 2026 01:52:30 +0000 Subject: [PATCH 5/9] Leave a state set that allows the call in every state out of both sides of the summary Co-Authored-By: Claude Opus 5.5 --- client/src/webview/views/fsm/fsm.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/client/src/webview/views/fsm/fsm.ts b/client/src/webview/views/fsm/fsm.ts index a80409e..b7239e4 100644 --- a/client/src/webview/views/fsm/fsm.ts +++ b/client/src/webview/views/fsm/fsm.ts @@ -107,15 +107,15 @@ function getStateErrorSummary(sms: LJStateMachine[]): string { 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 does not constrain it + // a state set in which every state allows the call does not constrain it, so it is left out const constraining = errors.filter(({ sm, focus }) => focus.acceptedStates.length < sm.states.length); - const expectedErrors = constraining.length > 0 ? constraining : errors; - const knownErrors = errors.filter(({ focus }) => !focus.unknownState); + const shownErrors = constraining.length > 0 ? constraining : errors; + const knownErrors = shownErrors.filter(({ focus }) => !focus.unknownState); if (errors.every(({ sm }) => sm.stateSetName)) { - return `${renderInvalidCall(call)} \u00B7 expected ${renderNamedStateSets(expectedErrors, focus => focus.acceptedStates)} \u00B7 actual ${getNamedActualStates(knownErrors)}`; + 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(expectedErrors.map(({ focus }) => focus.acceptedStates)); + 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}`; } From 18a984a300f549092319874c9f0c8d9100434443 Mon Sep 17 00:00:00 2001 From: Catarina Gamboa Date: Tue, 10 Nov 2026 01:57:05 +0000 Subject: [PATCH 6/9] Keep hasWrongState private to diagram-focus Co-Authored-By: Claude Opus 5.5 --- client/src/webview/diagram-focus.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/webview/diagram-focus.ts b/client/src/webview/diagram-focus.ts index 0faf348..4b62d36 100644 --- a/client/src/webview/diagram-focus.ts +++ b/client/src/webview/diagram-focus.ts @@ -76,7 +76,7 @@ export function orderStateMachines(sms: LJStateMachine[]): LJStateMachine[] { /** * Whether the receiver may be in a state of this state set that does not allow the call */ -export function hasWrongState(sm: LJStateMachine): boolean { +function hasWrongState(sm: LJStateMachine): boolean { return (getStateErrorFocus(sm)?.invalidStates.length ?? 0) > 0; } From eab45669454391291abfd127f0495e436dcf7a52 Mon Sep 17 00:00:00 2001 From: Catarina Gamboa Date: Tue, 10 Nov 2026 12:19:31 +0000 Subject: [PATCH 7/9] State Machine view: write conditions as in the spec, before and after the method A transition label reads [condition before] method [condition after] on one line, with each condition written as the spec writes it. The unmet clauses of a condition are marked, also next to clauses that are not about a state. Co-Authored-By: Claude Opus 5.5 --- client/src/webview/diagram-focus.spec.ts | 22 +++++----- client/src/webview/diagram.ts | 52 +++++++++--------------- client/src/webview/script.spec.ts | 2 +- client/src/webview/state-conditions.ts | 24 +++++++---- client/src/webview/styles.ts | 6 +++ 5 files changed, 54 insertions(+), 52 deletions(-) diff --git a/client/src/webview/diagram-focus.spec.ts b/client/src/webview/diagram-focus.spec.ts index c263b63..8aff9d0 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', () => { @@ -432,18 +432,16 @@ describe('a state error on several state sets', () => { const sms = door(); const [doorState, lockState] = sms.map(sm => lines(createMermaidDiagram(sm, 'TB', true, false, sms))); - expect(lockState).toContain(`unlocked --> unlocked : ${calledMethod('open')} ${guard('doorState: closed')}`); - expect(doorState).toContain(`closed --> opened : open ${guard(unmet('lockState: unlocked'))}`); - // a != on a state set with two states is its other state - expect(doorState).toContain(`closed --> closed : lock ${guard('lockState: unlocked')}`); + 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 : open ${guard(unmet('lockState: unlocked'))}`, 'closed --> closed : lock'])); + .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) != jammed && lockState(this) == unlocked'; - sms[1].states.push('jammed'); + sms[0].transitions[0].fromCondition = 'lockState(this) == unlocked && size(this) > 0'; expect(lines(createMermaidDiagram(sms[0], 'TB', true, false, sms))) - .toContain(`closed --> opened : open ${guard(`lockState: unlocked or locked, ${unmet('lockState: unlocked')}`)}`); + .toContain(`closed --> opened : ${guard(`${unmet('lockState(this) == unlocked')} && size(this) > 0`)} 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')}`); diff --git a/client/src/webview/diagram.ts b/client/src/webview/diagram.ts index f72fbaa..e3c61cd 100644 --- a/client/src/webview/diagram.ts +++ b/client/src/webview/diagram.ts @@ -2,7 +2,7 @@ import type { LJStateMachine } from "../types/fsm"; import { copyToClipboard } from "./clipboard"; import { INVALID_STATE_ICON, VALID_STATE_ICON, getRejectingStateSets, getRelatedStates, getStateErrorFocus } from "./diagram-focus"; import { placePopover, type Box, type Point } from "./popover-placement"; -import { parseStateConditions, type StateCondition } from "./state-conditions"; +import { getConditionParts, type StateCondition } from "./state-conditions"; // constants const MIN_ZOOM = 0.2; @@ -182,13 +182,14 @@ function getCollapsedLabel(bright: string[], dimmed: CollapsibleLabel[], noun: ' } /** - * Gets the label of a transition: its method with the condition it needs before, and the one it sets after + * 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: FromConditionLabel | null, + fromCondition: string | null, toCondition?: string | null, showConditions = false, isFailedCall = false, @@ -197,13 +198,11 @@ function getTransitionLabel( const methodLabel = isFailedCall ? `${escapedLabel}` : escapedLabel; - // a condition written as states reads as a guard of the method, on its line - const guard = fromCondition?.isGuard ? fromCondition.label : ''; return [ - guard ? '' : fromCondition?.label, - guard ? `${methodLabel} ${guard}` : methodLabel, + fromCondition, + methodLabel, showConditions ? getConditionLabel('post', toCondition) : '', - ].filter(Boolean).join('
'); + ].filter(Boolean).join(' '); } /** @@ -213,8 +212,8 @@ function hasUnmetCondition(transition: LJStateMachine['transitions'][number], sm stateSets: LJStateMachine[], rejectingStateSets: LJStateMachine[]): boolean { if (rejectingStateSets.length === 0 || !transition.fromCondition || transition.label !== sm.errorContext?.calledMethod || !sm.errorContext.possibleStates.includes(transition.from)) return false; - const conditions = parseStateConditions(transition.fromCondition, stateSets); - return !!conditions?.some(condition => isUnmetCondition(condition, rejectingStateSets)); + 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 @@ -223,31 +222,20 @@ function isUnmetCondition({ stateSet, states }: StateCondition, rejectingStateSe } /** - * Gets the label of the condition a transition needs of the other state sets, - * written as their states when it is only about them, e.g. [lockState: unlocked] + * 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): FromConditionLabel | null { + rejectingStateSets: LJStateMachine[] | null, showConditions: boolean): string | null { if (!condition || (!showConditions && !rejectingStateSets)) return null; - const conditions = parseStateConditions(condition, stateSets); - if (!conditions) return { label: getConditionLabel('pre', condition), isGuard: false }; - const guards = conditions.map(stateCondition => { - const text = escapeMermaidLabel(getStateConditionText(stateCondition)); - return rejectingStateSets && isUnmetCondition(stateCondition, rejectingStateSets) ? `${text}` : text; + const parts = getConditionParts(condition, stateSets).map(({ text, stateCondition }) => { + const escaped = escapeMermaidLabel(text); + const unmet = rejectingStateSets && stateCondition && isUnmetCondition(stateCondition, rejectingStateSets); + return unmet ? `${escaped}` : escaped; }); - return { label: `[${guards.join(', ')}]`, isGuard: true }; -} - -// the label of a transition's condition before, and whether it is written as the states it needs -type FromConditionLabel = { label: string; isGuard: boolean }; - -// the states a condition allows, after the name of their state set if it has one, e.g. lockState: unlocked -function getStateConditionText({ stateSet, states }: StateCondition): string { - const allowed = states.join(' or '); - return stateSet.stateSetName ? `${stateSet.stateSetName}: ${allowed}` : allowed; + return getConditionLabel('pre', parts.join(escapeMermaidLabel(' && ')), false); } function getInitialTransitionLabel(constructorSignature?: string | null, toCondition?: string | null, @@ -255,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 { 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/state-conditions.ts b/client/src/webview/state-conditions.ts index b8a5905..f1c5c3a 100644 --- a/client/src/webview/state-conditions.ts +++ b/client/src/webview/state-conditions.ts @@ -16,15 +16,25 @@ const STATE_FIRST = /^(\w+)\s*(==|!=)\s*(\w+)\(this\)$/; const STATE_ONLY = /^(\w+)\(this\)$/; /** - * Reads a transition's condition as the states it needs of the class's state sets - * @param condition the condition as written in the spec, e.g. lockState(this) != locked && mode(this) == fast + * 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 &&, 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 condition on each state set, or null when a part of it is not about a state + * @returns the parts, or a single part without states when the condition has an || */ -export function parseStateConditions(condition: string, sms: LJStateMachine[]): StateCondition[] | null { - if (condition.includes('||')) return null; - const conditions = condition.split('&&').map(part => parseStateCondition(unwrap(part.trim()), sms)); - return conditions.every(Boolean) ? conditions as StateCondition[] : null; +export function getConditionParts(condition: string, sms: LJStateMachine[]): ConditionPart[] { + if (condition.includes('||')) return [{ text: condition, stateCondition: null }]; + return condition.split('&&').map(part => { + const text = part.trim(); + return { text, stateCondition: parseStateCondition(unwrap(text), sms) }; + }); } function parseStateCondition(text: string, sms: LJStateMachine[]): StateCondition | null { diff --git a/client/src/webview/styles.ts b/client/src/webview/styles.ts index a181633..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; From b51cc60f0f4994c9db82bb59966af396ee0b8b72 Mon Sep 17 00:00:00 2001 From: Catarina Gamboa Date: Tue, 10 Nov 2026 12:21:38 +0000 Subject: [PATCH 8/9] State Machine view: read the state clauses of a condition next to a parenthesised || Co-Authored-By: Claude Opus 5.5 --- client/src/webview/diagram-focus.spec.ts | 4 ++-- client/src/webview/state-conditions.ts | 29 ++++++++++++++++++------ 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/client/src/webview/diagram-focus.spec.ts b/client/src/webview/diagram-focus.spec.ts index 8aff9d0..baada7d 100644 --- a/client/src/webview/diagram-focus.spec.ts +++ b/client/src/webview/diagram-focus.spec.ts @@ -439,9 +439,9 @@ describe('a state error on several state sets', () => { 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'; + 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`)} open`); + .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')}`); diff --git a/client/src/webview/state-conditions.ts b/client/src/webview/state-conditions.ts index f1c5c3a..221f9b8 100644 --- a/client/src/webview/state-conditions.ts +++ b/client/src/webview/state-conditions.ts @@ -24,17 +24,32 @@ export type ConditionPart = { }; /** - * Splits a transition's condition into its parts joined by &&, reading each one as the states it needs + * 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, or a single part without states when the condition has an || + * @returns the parts, a part with an || is not read as states */ export function getConditionParts(condition: string, sms: LJStateMachine[]): ConditionPart[] { - if (condition.includes('||')) return [{ text: condition, stateCondition: null }]; - return condition.split('&&').map(part => { - const text = part.trim(); - return { text, stateCondition: parseStateCondition(unwrap(text), sms) }; - }); + 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 { From 9831b863b4401a21a2da92c73aa52ff61f1a3151 Mon Sep 17 00:00:00 2001 From: Catarina Gamboa Date: Tue, 10 Nov 2026 12:24:21 +0000 Subject: [PATCH 9/9] State Machine view: name every state set when a combination of states rejects the call Co-Authored-By: Claude Opus 5.5 --- client/src/webview/views/fsm/fsm.spec.ts | 6 ++++++ client/src/webview/views/fsm/fsm.ts | 6 ++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/client/src/webview/views/fsm/fsm.spec.ts b/client/src/webview/views/fsm/fsm.spec.ts index ae20b92..1d4110c 100644 --- a/client/src/webview/views/fsm/fsm.spec.ts +++ b/client/src/webview/views/fsm/fsm.spec.ts @@ -385,6 +385,12 @@ describe('state machine view', () => { 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', () => { diff --git a/client/src/webview/views/fsm/fsm.ts b/client/src/webview/views/fsm/fsm.ts index b7239e4..1f06454 100644 --- a/client/src/webview/views/fsm/fsm.ts +++ b/client/src/webview/views/fsm/fsm.ts @@ -107,9 +107,11 @@ function getStateErrorSummary(sms: LJStateMachine[]): string { 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 does not constrain it, so it is left out + // 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 = constraining.length > 0 ? constraining : errors; + 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)}`;