From bb889b9983facafe35e3c6d696158cae599448b8 Mon Sep 17 00:00:00 2001 From: Catarina Gamboa Date: Tue, 20 Oct 2026 23:49:19 +0100 Subject: [PATCH 1/4] Focus the state machine view on the selected state error When the State Machine view is opened from a state refinement error, start with a reduced diagram of the found state, the required states, and the states on a shortest path between them. The rejected call is a note on the found state instead of a highlighted transition, so it never reads as a successful transition, and it is shown even when the method is absent from the general diagram or merged into another label. A summary lists the rejected call, Found, and Required with icons and text, and a button toggles between the focused and the full diagram. The view uses the receiver, expected states, conditions, and definite found state from the liquidjava-fsm error context. When Found is not one definite state, the found condition is shown without marking a single state. Error contexts without these fields keep the previous highlighting by label. Co-Authored-By: Claude Opus 5.5 --- client/src/types/fsm.ts | 5 + client/src/webview/diagram-focus.spec.ts | 236 +++++++++++++++++++++++ client/src/webview/diagram-focus.ts | 95 +++++++++ client/src/webview/diagram.ts | 93 ++++++++- client/src/webview/script.spec.ts | 42 ++++ client/src/webview/script.ts | 17 +- client/src/webview/styles.ts | 70 +++++++ client/src/webview/views/fsm/fsm.spec.ts | 43 +++++ client/src/webview/views/fsm/fsm.ts | 40 +++- 9 files changed, 631 insertions(+), 10 deletions(-) create mode 100644 client/src/webview/diagram-focus.spec.ts create mode 100644 client/src/webview/diagram-focus.ts diff --git a/client/src/types/fsm.ts b/client/src/types/fsm.ts index 0d252ec..1003955 100644 --- a/client/src/types/fsm.ts +++ b/client/src/types/fsm.ts @@ -11,4 +11,9 @@ export type LJStateMachine = { export type LJStateMachineErrorContext = { calledMethod: string | null; actualStates: string[]; + receiver?: string | null; + expectedStates?: string[]; + expectedCondition?: string | null; + foundCondition?: string | null; + definiteFoundState?: string | null; }; diff --git a/client/src/webview/diagram-focus.spec.ts b/client/src/webview/diagram-focus.spec.ts new file mode 100644 index 0000000..aeae765 --- /dev/null +++ b/client/src/webview/diagram-focus.spec.ts @@ -0,0 +1,236 @@ +import { describe, expect, it } from 'vitest'; +import type { LJStateMachine, LJStateMachineErrorContext } from '../types/fsm'; +import { createMermaidDiagram } from './diagram'; +import { getFocusedStates, getRejectedCallText, getStateErrorFocus } from './diagram-focus'; + +// DefaultMutableTreeNode from the state error focus issue (#117): +// root.add(...); root.removeAllChildren(); root.getFirstChild(); // state error +function treeNode(errorContext: Partial | null = {}): LJStateMachine { + return { + className: 'DefaultMutableTreeNode', + states: ['childrenAllowed', 'maybeHasChildren'], + initialTransitions: [{ to: 'childrenAllowed', constructorSignature: 'DefaultMutableTreeNode(Object)' }], + transitions: [ + { from: 'childrenAllowed', to: 'maybeHasChildren', label: 'add' }, + { from: 'maybeHasChildren', to: 'childrenAllowed', label: 'removeAllChildren' }, + { from: 'maybeHasChildren', to: 'maybeHasChildren', label: 'getFirstChild' }, + ], + errorContext: errorContext && { + calledMethod: 'getFirstChild', + actualStates: ['childrenAllowed'], + receiver: 'root', + expectedStates: ['maybeHasChildren'], + expectedCondition: 'maybeHasChildren(root)', + foundCondition: 'childrenAllowed(root)', + definiteFoundState: 'childrenAllowed', + ...errorContext, + }, + }; +} + +// java.net.Socket-like machine with many states, self loops, and close() from every open state +function socket(): LJStateMachine { + const states = ['unconnected', 'bound', 'connected', 'inputShutdown', 'outputShutdown', 'bothShutdown', 'closed']; + const transitions: LJStateMachine['transitions'] = [ + { from: 'unconnected', to: 'bound', label: 'bind' }, + { from: 'unconnected', to: 'connected', label: 'connect' }, + { from: 'bound', to: 'connected', label: 'connect' }, + { from: 'connected', to: 'connected', label: 'sendUrgentData' }, + { from: 'inputShutdown', to: 'inputShutdown', label: 'sendUrgentData' }, + { from: 'connected', to: 'inputShutdown', label: 'shutdownInput' }, + { from: 'connected', to: 'outputShutdown', label: 'shutdownOutput' }, + { from: 'inputShutdown', to: 'bothShutdown', label: 'shutdownOutput' }, + { from: 'outputShutdown', to: 'bothShutdown', label: 'shutdownInput' }, + ...states.filter(state => state !== 'closed').map(state => ({ from: state, to: 'closed', label: 'close' })), + ...states.map(state => ({ from: state, to: state, label: 'isClosed' })), + ]; + return { + className: 'Socket', + states, + initialTransitions: [{ to: 'unconnected', constructorSignature: 'Socket()' }], + transitions, + errorContext: { + calledMethod: 'sendUrgentData', + actualStates: ['bound'], + receiver: 'socket', + expectedStates: ['connected', 'inputShutdown'], + expectedCondition: 'connected(socket) || inputShutdown(socket)', + foundCondition: 'bound(socket)', + definiteFoundState: 'bound', + }, + }; +} + +function lines(diagram: string): string[] { + return diagram.split('\n').map(line => line.trim()); +} + +describe('state error focus', () => { + it('reads the found state, required states, and rejected call from the error context', () => { + const focus = getStateErrorFocus(treeNode()); + + expect(focus).toEqual({ + calledMethod: 'getFirstChild', + receiver: 'root', + foundState: 'childrenAllowed', + possibleStates: [], + requiredStates: ['maybeHasChildren'], + foundCondition: 'childrenAllowed(root)', + expectedCondition: 'maybeHasChildren(root)', + }); + expect(getRejectedCallText(focus!)).toBe('root.getFirstChild()'); + expect(getFocusedStates(treeNode(), focus!)).toEqual(['childrenAllowed', 'maybeHasChildren']); + }); + + it('focuses the tree node example on the found state, the rejected call, and the required state', () => { + const diagram = lines(createMermaidDiagram(treeNode(), 'TB', false, true)); + + expect(diagram).toEqual(expect.arrayContaining([ + 'childrenAllowed : childrenAllowed', + 'childrenAllowed : ◉ Found for this
receiver at the call', + 'maybeHasChildren : maybeHasChildren', + 'maybeHasChildren : ✓ Required for this call', + 'note right of childrenAllowed : ⊘ root.getFirstChild()
Call rejected from this state', + 'class childrenAllowed ljStateErrorFound', + 'class maybeHasChildren ljStateErrorRequired', + 'childrenAllowed --> maybeHasChildren : add', + 'maybeHasChildren --> childrenAllowed : removeAllChildren', + ])); + // the rejected call is not drawn as a transition, and unrelated content is left out + expect(diagram.filter(line => line.includes('-->') && line.includes('getFirstChild'))).toEqual([]); + expect(diagram.some(line => line.startsWith('[*]'))).toBe(false); + expect(diagram.join('\n')).not.toContain('lj-state-error-method'); + }); + + it('marks the error on the full diagram without highlighting transitions by label', () => { + const diagram = lines(createMermaidDiagram(treeNode(), 'TB', false, false)); + + expect(diagram).toEqual(expect.arrayContaining([ + '[*] --> childrenAllowed : DefaultMutableTreeNode(Object)', + 'maybeHasChildren --> maybeHasChildren : getFirstChild', + 'note right of childrenAllowed : ⊘ root.getFirstChild()
Call rejected from this state', + 'class childrenAllowed ljStateErrorFound', + 'class maybeHasChildren ljStateErrorRequired', + ])); + expect(diagram.join('\n')).not.toContain('lj-state-error-method'); + expect(diagram.join('\n')).not.toContain('ljStateErrorActual'); + }); + + it('focuses a large machine on the error and the shortest path to a required state', () => { + const sm = socket(); + const focus = getStateErrorFocus(sm)!; + expect(getFocusedStates(sm, focus)).toEqual(['bound', 'connected', 'inputShutdown']); + + const focused = createMermaidDiagram(sm, 'TB', false, true); + expect(lines(focused)).toEqual(expect.arrayContaining([ + 'bound : ◉ Found for this
receiver at the call', + 'connected : ✓ Required for this call', + 'inputShutdown : ✓ Required for this call', + 'note right of bound : ⊘ socket.sendUrgentData()
Call rejected from this state', + 'bound --> connected : connect', + 'connected --> inputShutdown : shutdownInput', + ])); + for (const unrelated of ['unconnected', 'outputShutdown', 'bothShutdown', 'closed', '[*]', 'isClosed', 'sendUrgentData :']) { + expect(focused).not.toContain(unrelated); + } + + const full = createMermaidDiagram(sm, 'TB', false, false); + for (const state of sm.states) expect(full).toContain(state); + expect(full).toContain('[*] --> unconnected : Socket()'); + }); + + it('shows the found condition without a single found state when it is not definite', () => { + const sm: LJStateMachine = { + className: 'Door', + states: ['open', 'closed', 'locked'], + initialTransitions: [{ to: 'open' }, { to: 'closed' }], + transitions: [ + { from: 'open', to: 'closed', label: 'close' }, + { from: 'closed', to: 'locked', label: 'lock' }, + { from: 'locked', to: 'closed', label: 'unlock' }, + ], + errorContext: { + calledMethod: 'unlock', + actualStates: ['open', 'closed'], + receiver: 'door', + expectedStates: ['locked'], + expectedCondition: 'locked(door)', + foundCondition: 'flag ? open(door) : closed(door)', + definiteFoundState: null, + }, + }; + + const diagram = lines(createMermaidDiagram(sm, 'TB', false, true)); + expect(diagram).toEqual(expect.arrayContaining([ + 'state "◉ Found: flag ? open(door) : closed(door)" as lj_found_condition', + 'open : ? Possible for this
receiver at the call', + 'closed : ? Possible for this
receiver at the call', + 'locked : ✓ Required for this call', + 'note right of lj_found_condition : ⊘ door.unlock()
Call rejected from the found state', + 'class lj_found_condition ljStateErrorFoundCondition', + 'class open,closed ljStateErrorPossible', + 'closed --> locked : lock', + ])); + expect(diagram.join('\n')).not.toContain('ljStateErrorFound '); + expect(diagram.some(line => line.startsWith('class') && line.endsWith('ljStateErrorFound'))).toBe(false); + }); + + it('does not treat a trivially true found condition as the found state', () => { + const sm = treeNode({ actualStates: [], foundCondition: 'true', definiteFoundState: null }); + + expect(getStateErrorFocus(sm)?.foundCondition).toBeNull(); + expect(createMermaidDiagram(sm, 'TB', false, true)).toContain('state "◉ Found: no single state" as lj_found_condition'); + }); + + it('escapes quotes in a found condition shown as a state name', () => { + const sm = treeNode({ foundCondition: 'name == "x"', definiteFoundState: null }); + + expect(createMermaidDiagram(sm, 'TB', false, true)).toContain('state "◉ Found: name == #quot;x#quot;" as lj_found_condition'); + }); + + it('shows the rejected call even when the method is absent from the general diagram', () => { + const sm = treeNode(); + sm.transitions = sm.transitions.filter(transition => transition.label !== 'getFirstChild'); + + for (const focusOnError of [true, false]) { + expect(createMermaidDiagram(sm, 'TB', false, focusOnError)) + .toContain('note right of childrenAllowed : ⊘ root.getFirstChild()
Call rejected from this state'); + } + }); + + it('shows the rejected call separately from a merged transition label', () => { + const sm = treeNode(); + sm.transitions.push({ from: 'childrenAllowed', to: 'maybeHasChildren', label: 'getFirstChild' }); + + const diagram = createMermaidDiagram(sm, 'TB', false, false); + expect(diagram).toContain('childrenAllowed --> maybeHasChildren : add
getFirstChild'); + expect(diagram).toContain('note right of childrenAllowed : ⊘ root.getFirstChild()'); + expect(diagram).not.toContain('lj-state-error-method'); + }); + + it('keeps the previous highlighting for an error context without the error states', () => { + const sm = treeNode(); + sm.errorContext = { calledMethod: 'getFirstChild', actualStates: ['childrenAllowed'] }; + + expect(getStateErrorFocus(sm)).toBeNull(); + const diagram = createMermaidDiagram(sm, 'TB', false, true); + expect(diagram).toBe(createMermaidDiagram(sm, 'TB', false, false)); + expect(diagram).toContain('maybeHasChildren --> maybeHasChildren : getFirstChild'); + expect(diagram).toContain('class childrenAllowed ljStateErrorActual'); + expect(diagram).not.toContain('note'); + }); + + it('leaves the general diagram unchanged without an error', () => { + const sm = treeNode(null); + + expect(getStateErrorFocus(sm)).toBeNull(); + expect(createMermaidDiagram(sm, 'TB', false, true)).toBe([ + 'stateDiagram-v2', + ' direction TB', + ' [*] --> childrenAllowed : DefaultMutableTreeNode(Object)', + ' childrenAllowed --> maybeHasChildren : add', + ' maybeHasChildren --> childrenAllowed : removeAllChildren', + ' maybeHasChildren --> maybeHasChildren : getFirstChild', + ].join('\n')); + }); +}); diff --git a/client/src/webview/diagram-focus.ts b/client/src/webview/diagram-focus.ts new file mode 100644 index 0000000..dde3fd8 --- /dev/null +++ b/client/src/webview/diagram-focus.ts @@ -0,0 +1,95 @@ +import type { LJStateMachine } from "../types/fsm"; + +// labels shown next to the states and call of a state error +export const FOUND_STATE_ICON = '\u25C9'; +export const FOUND_STATE_TEXT = 'Found for this receiver at the call'; +export const POSSIBLE_STATE_ICON = '?'; +export const POSSIBLE_STATE_TEXT = 'Possible for this receiver at the call'; +export const REQUIRED_STATE_ICON = '\u2713'; +export const REQUIRED_STATE_TEXT = 'Required for this call'; +export const REJECTED_CALL_ICON = '\u2298'; +export const REJECTED_CALL_TEXT = 'Call rejected from this state'; +export const REJECTED_CALL_UNKNOWN_STATE_TEXT = 'Call rejected from the found state'; +export const UNKNOWN_FOUND_STATE_TEXT = 'no single state'; + +/** + * The selected state error of a single state machine, with the states it involves + */ +export type StateErrorFocus = { + calledMethod: string | null; + receiver: string | null; + foundState: string | null; + possibleStates: string[]; + requiredStates: string[]; + foundCondition: string | null; + expectedCondition: string | null; +}; + +/** + * Gets the state error to focus on from the error context of a state machine + * @param sm + * @returns the state error focus, or null when there is no error or the server only reports the called method and found states + */ +export function getStateErrorFocus(sm: LJStateMachine | undefined): StateErrorFocus | null { + const context = sm?.errorContext; + if (!context?.expectedStates) return null; + + const foundState = context.definiteFoundState && sm.states.includes(context.definiteFoundState) + ? context.definiteFoundState + : null; + return { + calledMethod: context.calledMethod, + receiver: context.receiver ?? null, + foundState, + possibleStates: foundState ? [] : sm.states.filter(state => context.actualStates.includes(state)), + requiredStates: sm.states.filter(state => state !== foundState && context.expectedStates?.includes(state)), + // a trivially true condition tells nothing about the state of the receiver + foundCondition: context.foundCondition && context.foundCondition !== 'true' ? context.foundCondition : null, + expectedCondition: context.expectedCondition ?? null, + }; +} + +/** + * Gets the states shown when focusing on a state error: the found states, the required states, + * and the states on a shortest path between them + * @param sm + * @param focus + * @returns the focused states, in the order of the state machine + */ +export function getFocusedStates(sm: LJStateMachine, focus: StateErrorFocus): string[] { + const sources = focus.foundState ? [focus.foundState] : focus.possibleStates; + const focused = new Set([...sources, ...focus.requiredStates, ...getShortestPath(sm, sources, focus.requiredStates)]); + return sm.states.filter(state => focused.has(state)); +} + +function getShortestPath(sm: LJStateMachine, sources: string[], targets: string[]): string[] { + const previous = new Map(sources.map(source => [source, null])); + const queue = [...sources]; + while (queue.length > 0) { + const state = queue.shift()!; + if (targets.includes(state)) { + const path: string[] = []; + for (let current: string | null | undefined = state; current; current = previous.get(current)) { + path.unshift(current); + } + return path; + } + sm.transitions + .filter(transition => transition.from === state && !previous.has(transition.to)) + .forEach(transition => { + previous.set(transition.to, state); + queue.push(transition.to); + }); + } + return []; +} + +/** + * Gets the text shown for the rejected call, e.g. root.getFirstChild() + * @param focus + * @returns the call text, or null when the called method is unknown + */ +export function getRejectedCallText(focus: StateErrorFocus): string | null { + if (!focus.calledMethod) return null; + return `${focus.receiver ? `${focus.receiver}.` : ''}${focus.calledMethod}()`; +} diff --git a/client/src/webview/diagram.ts b/client/src/webview/diagram.ts index d908714..7826b1d 100644 --- a/client/src/webview/diagram.ts +++ b/client/src/webview/diagram.ts @@ -1,5 +1,11 @@ import type { LJStateMachine } from "../types/fsm"; import { copyToClipboard } from "./clipboard"; +import { + FOUND_STATE_ICON, FOUND_STATE_TEXT, POSSIBLE_STATE_ICON, POSSIBLE_STATE_TEXT, + REJECTED_CALL_ICON, REJECTED_CALL_TEXT, REJECTED_CALL_UNKNOWN_STATE_TEXT, + REQUIRED_STATE_ICON, REQUIRED_STATE_TEXT, UNKNOWN_FOUND_STATE_TEXT, + getFocusedStates, getRejectedCallText, getStateErrorFocus, type StateErrorFocus, +} from "./diagram-focus"; // constants const MIN_ZOOM = 0.2; @@ -7,6 +13,8 @@ const MAX_ZOOM = 5; const ZOOM_BUTTON_FACTOR = 1.5; const SCROLL_ZOOM_IN_FACTOR = 1.05; const SCROLL_ZOOM_OUT_FACTOR = 0.95; +const STATE_ERROR_FOUND_CONDITION_ID = 'lj_found_condition'; +const STATE_ERROR_LABEL_LINE_LENGTH = 24; // state variables let zoomLevel = 1; @@ -19,30 +27,45 @@ let startY = 0; /** * Converts a StateMachine object to a Mermaid state diagram string * @param sm + * @param orientation + * @param showConditions + * @param focusOnError whether to only show the states involved in the selected state error * @returns Mermaid diagram string */ export function createMermaidDiagram( sm: LJStateMachine | undefined, orientation: "LR" | "TB", showConditions = false, + focusOnError = false, ): string { if (!sm) return ''; const lines: string[] = []; + const errorFocus = getStateErrorFocus(sm); + const focusedStates = focusOnError && errorFocus ? getFocusedStates(sm, errorFocus) : null; lines.push('stateDiagram-v2'); lines.push(` direction ${orientation}`); + + // declare the error states first so the focused layout starts from the found state + if (errorFocus && focusedStates) lines.push(...getStateErrorLines(errorFocus)); // initial transitions - sm.initialTransitions.forEach(transition => { - const label = getInitialTransitionLabel(transition.constructorSignature, transition.toCondition, showConditions); - lines.push(` [*] --> ${transition.to}${label ? ` : ${label}` : ''}`); - }); + if (!focusedStates) { + sm.initialTransitions.forEach(transition => { + const label = getInitialTransitionLabel(transition.constructorSignature, transition.toCondition, showConditions); + lines.push(` [*] --> ${transition.to}${label ? ` : ${label}` : ''}`); + }); + } // group transitions by from/to states and merge labels const transitionMap = new Map(); sm.transitions.forEach(transition => { - const isCalledMethod = transition.label === sm.errorContext?.calledMethod; + // the focused view only keeps the transitions between its states, without self loops + if (focusedStates && (transition.from === transition.to + || !focusedStates.includes(transition.from) || !focusedStates.includes(transition.to))) return; + // without the error states, the called method can only be found by its transition label + const isCalledMethod = !errorFocus && transition.label === sm.errorContext?.calledMethod; const label = getTransitionLabel( transition.label, transition.fromCondition, @@ -62,6 +85,11 @@ export function createMermaidDiagram( lines.push(` ${from} --> ${to} : ${mergedLabel}`); }); + if (errorFocus) { + if (!focusedStates) lines.push(...getStateErrorLines(errorFocus)); + return lines.join('\n'); + } + const highlightedStates = sm.states.filter(state => sm.errorContext?.actualStates.includes(state)); if (highlightedStates.length > 0) { lines.push(` class ${highlightedStates.join(',')} ljStateErrorActual`); @@ -70,6 +98,61 @@ export function createMermaidDiagram( return lines.join('\n'); } +/** + * Marks the found and required states of a state error and attaches the rejected call to the found state + * The rejected call is a note rather than a transition, since the call does not change the state + * @param focus + * @returns Mermaid lines + */ +function getStateErrorLines(focus: StateErrorFocus): string[] { + const lines: string[] = []; + const foundNode = focus.foundState ?? STATE_ERROR_FOUND_CONDITION_ID; + if (focus.foundState) { + lines.push(...getStateDescriptionLines(focus.foundState, `${FOUND_STATE_ICON} ${FOUND_STATE_TEXT}`)); + } else { + // quotes would end the quoted state name + const condition = focus.foundCondition + ? escapeMermaidLabel(focus.foundCondition).replace(/\\"/g, '#quot;') + : UNKNOWN_FOUND_STATE_TEXT; + lines.push(` state "${FOUND_STATE_ICON} Found: ${condition}" as ${STATE_ERROR_FOUND_CONDITION_ID}`); + focus.possibleStates.forEach(state => { + lines.push(...getStateDescriptionLines(state, `${POSSIBLE_STATE_ICON} ${POSSIBLE_STATE_TEXT}`)); + }); + } + focus.requiredStates.forEach(state => { + lines.push(...getStateDescriptionLines(state, `${REQUIRED_STATE_ICON} ${REQUIRED_STATE_TEXT}`)); + }); + + const call = getRejectedCallText(focus); + const rejectedText = focus.foundState ? REJECTED_CALL_TEXT : REJECTED_CALL_UNKNOWN_STATE_TEXT; + const callLabel = call ? `${REJECTED_CALL_ICON} ${escapeMermaidLabel(call)}
${rejectedText}` : `${REJECTED_CALL_ICON} ${rejectedText}`; + lines.push(` note right of ${foundNode} : ${callLabel}`); + + lines.push(` class ${foundNode} ${focus.foundState ? 'ljStateErrorFound' : 'ljStateErrorFoundCondition'}`); + if (focus.possibleStates.length > 0 && !focus.foundState) { + lines.push(` class ${focus.possibleStates.join(',')} ljStateErrorPossible`); + } + if (focus.requiredStates.length > 0) { + lines.push(` class ${focus.requiredStates.join(',')} ljStateErrorRequired`); + } + return lines; +} + +function getStateDescriptionLines(state: string, description: string): string[] { + return [` ${state} : ${state}`, ` ${state} : ${wrapLabel(description)}`]; +} + +// state descriptions do not wrap, so long ones would shrink the whole diagram in narrow views +function wrapLabel(label: string): string { + const lines: string[] = []; + label.split(' ').forEach(word => { + const last = lines.length - 1; + if (last >= 0 && lines[last].length + word.length < STATE_ERROR_LABEL_LINE_LENGTH) lines[last] += ` ${word}`; + else lines.push(word); + }); + return lines.join('
'); +} + function getTransitionLabel( label: string, fromCondition?: string | null, diff --git a/client/src/webview/script.spec.ts b/client/src/webview/script.spec.ts index 25dfdca..2857b3c 100644 --- a/client/src/webview/script.spec.ts +++ b/client/src/webview/script.spec.ts @@ -141,6 +141,48 @@ describe('webview script', () => { ]); }); + it('opens a state error focused on the error and toggles the full diagram', () => { + const errorMachine: LJStateMachine = { + ...stateMachine, + transitions: [...stateMachine.transitions, { from: 'Closed', to: 'Closed', label: 'reopen()' }], + initialTransitions: [{ to: 'Open' }, { to: 'Closed' }], + errorContext: { + calledMethod: 'close', actualStates: ['Closed'], receiver: 'counter', expectedStates: ['Open'], + expectedCondition: 'Open(counter)', foundCondition: 'Closed(counter)', definiteFoundState: 'Closed', + }, + }; + const error: LJDiagnostic = { + title: 'State refinement error', message: 'State refinement error message', hint: null, + file: currentFile, position, category: 'error', type: 'state-refinement-error', translationTable: {}, + expected: 'Open(counter)', found: { implication: null as never, origin: null, simplification: 'Closed(counter)' }, + customMessage: '', declarationPosition: null, stateMachine: errorMachine, + }; + showDiagnostics([error]); + + click('.diagnostic-state-machine-btn'); + expect(root.querySelector('.nav-tab.selected')?.getAttribute('data-tab')).toBe('fsm'); + expect(root.querySelector('#diagram-focus-btn')?.textContent?.trim()).toBe('Show full diagram'); + expect(root.querySelector('.mermaid')?.textContent).toContain('note right of Closed'); + expect(root.querySelector('.mermaid')?.textContent).not.toContain('[*]'); + + click('#diagram-focus-btn'); + expect(root.querySelector('#diagram-focus-btn')?.textContent?.trim()).toBe('Focus on error'); + expect(root.querySelector('.mermaid')?.textContent).toContain('[*] --> Open'); + expect(root.querySelector('.mermaid')?.textContent).toContain('Closed --> Closed : reopen()'); + expect(root.querySelector('.mermaid')?.textContent).toContain('note right of Closed'); + + click('#diagram-focus-btn'); + expect(root.querySelector('#diagram-focus-btn')?.textContent?.trim()).toBe('Show full diagram'); + expect(root.querySelector('.mermaid')?.textContent).not.toContain('[*]'); + + // the general diagram of the file has no error to focus on + click('[data-tab="diagnostics"]'); + receive({ type: 'fsm', sm: stateMachine }); + click('[data-tab="fsm"]'); + expect(root.querySelector('#diagram-focus-btn')).toBeNull(); + expect(root.querySelector('.mermaid')?.textContent).not.toContain('note'); + }); + it('toggles between file and workspace errors by clicking scope buttons', () => { showDiagnostics([ diagnostic(currentFile, 'Current error'), diff --git a/client/src/webview/script.ts b/client/src/webview/script.ts index 05b6ed0..250b643 100644 --- a/client/src/webview/script.ts +++ b/client/src/webview/script.ts @@ -39,6 +39,7 @@ export function getScript(vscode: VSCodeApi, document: Document, window: Window) let status: ExtensionStatus = 'loading'; let diagramOrientation: "LR" | "TB" = "TB"; let showDiagramConditions = false; + let focusDiagramOnError = false; let currentDiagram: string = ''; let revealTimeout: ReturnType | undefined; const contextSectionState: ContextSectionState = { @@ -147,6 +148,8 @@ export function getScript(vscode: VSCodeApi, document: Document, window: Window) diagnosticStateMachine = diagnostic.stateMachine; diagnosticStateMachineFile = diagnostic.file; showDiagramConditions = false; + focusDiagramOnError = true; + resetZoom(document); currentDiagram = ''; updateView(); return; @@ -199,6 +202,15 @@ export function getScript(vscode: VSCodeApi, document: Document, window: Window) return; } + // toggle between the selected state error and the full diagram + if (target.closest?.('#diagram-focus-btn')) { + e.stopPropagation(); + focusDiagramOnError = !focusDiagramOnError; + resetZoom(document); + updateView(); + return; + } + // zoom in if (target.closest?.('#zoom-in-btn')) { e.stopPropagation(); @@ -402,9 +414,10 @@ export function getScript(vscode: VSCodeApi, document: Document, window: Window) break; case 'fsm': { const displayedStateMachine = diagnosticStateMachine ?? stateMachine; - const diagram = createMermaidDiagram(displayedStateMachine, diagramOrientation, showDiagramConditions); + const focusOnError = focusDiagramOnError && !!diagnosticStateMachine; + const diagram = createMermaidDiagram(displayedStateMachine, diagramOrientation, showDiagramConditions, focusOnError); currentDiagram = diagram; - renderStateMachineView(root, displayedStateMachine, diagram, diagramOrientation, showDiagramConditions); + renderStateMachineView(root, displayedStateMachine, diagram, diagramOrientation, showDiagramConditions, focusOnError); if (displayedStateMachine) renderMermaidDiagram(document, window); break; } diff --git a/client/src/webview/styles.ts b/client/src/webview/styles.ts index e26a633..d07ebeb 100644 --- a/client/src/webview/styles.ts +++ b/client/src/webview/styles.ts @@ -42,6 +42,7 @@ export function getStyles(): string { --lj-token-punctuation: var(--vscode-editor-foreground); --lj-state-cond-pre: var(--lj-token-identifier); --lj-state-cond-post: var(--lj-token-type); + --lj-state-required: var(--vscode-testing-iconPassed, var(--vscode-charts-green, #388a34)); } body.vscode-light { --lj-token-keyword: #0000FF; @@ -1016,5 +1017,74 @@ export function getStyles(): string { fill: var(--vscode-errorForeground) !important; stroke: var(--vscode-errorForeground) !important; } + .diagram-container .mermaid svg .ljStateErrorFound .label-container, + .diagram-container .mermaid svg .ljStateErrorFound .divider, + .diagram-container .mermaid svg .ljStateErrorFoundCondition .label-container { + stroke: var(--vscode-errorForeground) !important; + stroke-width: 2px !important; + } + .diagram-container .mermaid svg .ljStateErrorFoundCondition .label-container, + .diagram-container .mermaid svg .ljStateErrorPossible .label-container { + stroke-dasharray: 5 3; + } + .diagram-container .mermaid svg .ljStateErrorRequired .label-container, + .diagram-container .mermaid svg .ljStateErrorRequired .divider { + stroke: var(--lj-state-required) !important; + stroke-width: 2px !important; + } + .diagram-container .mermaid svg .note-cluster rect { + fill: none !important; + stroke: none !important; + } + .diagram-container .mermaid svg .statediagram-note .label-container path { + fill: color-mix(in srgb, var(--vscode-errorForeground) 12%, var(--vscode-editor-background)) !important; + stroke: var(--vscode-errorForeground) !important; + stroke-dasharray: 4 2; + } + .diagram-container .mermaid svg path.note-edge { + stroke: var(--vscode-errorForeground) !important; + stroke-dasharray: 4 3; + } + .diagram-error-summary { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 0.5rem 1rem; + margin-bottom: 0.75rem; + font-size: 0.85rem; + cursor: default; + user-select: text; + } + .diagram-error-items { + display: flex; + flex-direction: column; + gap: 0.25rem; + min-width: 0; + margin: 0; + padding: 0; + list-style: none; + } + .diagram-error-items li { + display: flex; + gap: 0.4rem; + overflow-wrap: anywhere; + } + .diagram-error-icon { + flex-shrink: 0; + width: 1em; + font-weight: 700; + text-align: center; + } + .diagram-error-call .diagram-error-icon, + .diagram-error-found .diagram-error-icon { + color: var(--vscode-errorForeground); + } + .diagram-error-required .diagram-error-icon { + color: var(--lj-state-required); + } + .diagram-focus-btn { + flex-shrink: 0; + } `; } diff --git a/client/src/webview/views/fsm/fsm.spec.ts b/client/src/webview/views/fsm/fsm.spec.ts index 4bc540c..b18f26e 100644 --- a/client/src/webview/views/fsm/fsm.spec.ts +++ b/client/src/webview/views/fsm/fsm.spec.ts @@ -109,6 +109,49 @@ describe('state machine view', () => { } }); + it('summarizes a selected state error with text and a toggle to the full diagram', () => { + const errorMachine = machine({ + errorContext: { + calledMethod: 'read', actualStates: ['Closed'], receiver: 'connection', + expectedStates: ['Open'], expectedCondition: 'Open(connection)', foundCondition: 'Closed(connection)', + definiteFoundState: 'Closed', + }, + }); + renderStateMachineView(document.body, errorMachine, diagram, 'TB', false, true); + + const items = Array.from(document.querySelectorAll('.diagram-error-items li'), node => node.textContent?.replace(/\s+/g, ' ').trim()); + expect(items).toEqual([ + '\u2298 Call rejected: connection.read()', + '\u25C9 Found: Closed(connection)', + '\u2713 Required: Open(connection)', + ]); + expect(button('diagram-focus-btn').textContent?.trim()).toBe('Show full diagram'); + + renderStateMachineView(document.body, errorMachine, diagram, 'TB', false, false); + expect(button('diagram-focus-btn').textContent?.trim()).toBe('Focus on error'); + }); + + it('says when the found condition does not establish a single state', () => { + renderStateMachineView(document.body, machine({ + errorContext: { + calledMethod: 'read', actualStates: [], receiver: 'connection', expectedStates: ['Open'], + expectedCondition: 'Open(connection)', foundCondition: 'true', definiteFoundState: null, + }, + }), diagram, 'TB', false, true); + + expect(document.querySelector('.diagram-error-found')?.textContent?.replace(/\s+/g, ' ').trim()).toBe('\u25C9 Found (no single state)'); + }); + + it.each([ + ['no error', null], + ['an error context without the error states', { calledMethod: 'open', actualStates: ['Closed'] }], + ])('omits the error summary with %s', (_, errorContext) => { + renderStateMachineView(document.body, machine({ errorContext }), diagram, 'TB', false, true); + + expect(document.querySelector('.diagram-error-summary')).toBeNull(); + expect(document.querySelector('#diagram-focus-btn')).toBeNull(); + }); + it('preserves the previous diagram container height when rerendering', () => { renderStateMachineView(document.body, machine(), diagram, 'TB', false); const previousContainer = document.querySelector('.diagram-container')!; diff --git a/client/src/webview/views/fsm/fsm.ts b/client/src/webview/views/fsm/fsm.ts index 263090d..1ef47c8 100644 --- a/client/src/webview/views/fsm/fsm.ts +++ b/client/src/webview/views/fsm/fsm.ts @@ -1,15 +1,20 @@ import type { LJStateMachine } from "../../../types/fsm"; +import { + FOUND_STATE_ICON, REJECTED_CALL_ICON, REQUIRED_STATE_ICON, UNKNOWN_FOUND_STATE_TEXT, getRejectedCallText, getStateErrorFocus, type StateErrorFocus, +} from "../../diagram-focus"; +import { renderHighlightedInlineExpression } from "../../highlighting"; import { renderCodiconButton } from "../../icons"; import { escapeHtml } from "../../utils"; import { renderMainHeader } from "../sections"; -export function renderStateMachineView(root: HTMLElement, sm: LJStateMachine | undefined, diagram: string, orientation: "LR" | "TB", showConditions: boolean) { +export function renderStateMachineView(root: HTMLElement, sm: LJStateMachine | undefined, diagram: string, orientation: "LR" | "TB", showConditions: boolean, focusOnError = false) { const previousDiagramContainer = root.querySelector('.diagram-container') as HTMLElement | null; const diagramHeight = previousDiagramContainer?.offsetHeight; - root.innerHTML = renderStateMachineViewHtml(sm, diagram, orientation, showConditions, diagramHeight); + root.innerHTML = renderStateMachineViewHtml(sm, diagram, orientation, showConditions, focusOnError, diagramHeight); } -function renderStateMachineViewHtml(sm: LJStateMachine | undefined, diagram: string, orientation: "LR" | "TB", showConditions: boolean, diagramHeight?: number): string { +function renderStateMachineViewHtml(sm: LJStateMachine | undefined, diagram: string, orientation: "LR" | "TB", showConditions: boolean, focusOnError: boolean, diagramHeight?: number): string { + const errorFocus = getStateErrorFocus(sm); const hasConditionExpansions = sm ? sm.initialTransitions.some(transition => !!transition.toCondition) || sm.transitions.some(transition => !!transition.fromCondition || !!transition.toCondition) @@ -35,6 +40,7 @@ function renderStateMachineViewHtml(sm: LJStateMachine | undefined, diagram: str ${renderCodiconButton("copy", { id: "copy-diagram-btn", className: "diagram-control-btn", title: "Copy Mermaid Source" })} + ${errorFocus ? renderStateErrorSummary(errorFocus, focusOnError) : ''} ${showConditions && hasConditionExpansions ? /*html*/`
Precondition @@ -50,3 +56,31 @@ function renderStateMachineViewHtml(sm: LJStateMachine | undefined, diagram: str
`; } + +function renderStateErrorSummary(focus: StateErrorFocus, focusOnError: boolean): string { + const call = getRejectedCallText(focus); + const found = focus.foundCondition ?? focus.foundState; + const foundNote = focus.foundState ? '' : ` (${UNKNOWN_FOUND_STATE_TEXT})`; + const required = focus.expectedCondition ?? (focus.requiredStates.length > 0 ? focus.requiredStates.join(' || ') : null); + return /*html*/` +
+
    +
  • + + Call rejected${call ? /*html*/`: ${escapeHtml(call)}` : ''} +
  • +
  • + + Found${found ? /*html*/`: ${renderHighlightedInlineExpression(found)}` : ''}${foundNote} +
  • +
  • + + Required${required ? /*html*/`: ${renderHighlightedInlineExpression(required)}` : ''} +
  • +
+ +
+ `; +} From 61829c1d6c37141aa19b4203d0062900c6e698b8 Mon Sep 17 00:00:00 2001 From: Catarina Gamboa Date: Tue, 20 Oct 2026 23:53:57 +0100 Subject: [PATCH 2/4] Outline found and required states that have a description Mermaid draws a state with a description as an outer rect and a divider line rather than a .label-container, so the found, required, and possible state styles only reached the divider. Target the outer rect as well and cover both node shapes with style tests. Co-Authored-By: Claude Opus 5.5 --- client/src/webview/styles.spec.ts | 55 +++++++++++++++++++++++++++++++ client/src/webview/styles.ts | 13 ++++---- 2 files changed, 61 insertions(+), 7 deletions(-) create mode 100644 client/src/webview/styles.spec.ts diff --git a/client/src/webview/styles.spec.ts b/client/src/webview/styles.spec.ts new file mode 100644 index 0000000..156dda3 --- /dev/null +++ b/client/src/webview/styles.spec.ts @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { getStyles } from './styles'; + +// node markup copied from Mermaid 11 state diagrams rendered in the webview +const describedState = (className: string) => /*html*/` + + + + `; +const plainState = (className: string) => /*html*/` + + + + `; + +function render(nodes: string) { + document.head.innerHTML = ``; + document.body.innerHTML = /*html*/` +
${nodes}
`; +} + +function stroke(selector: string): { color: string; dash: string } { + const style = getComputedStyle(document.querySelector(selector)!); + return { color: style.getPropertyValue('stroke'), dash: style.getPropertyValue('stroke-dasharray') }; +} + +afterEach(() => { + document.head.innerHTML = ''; + document.body.innerHTML = ''; +}); + +describe('state error styles', () => { + it('outlines found and required states that have a description', () => { + render(describedState('ljStateErrorFound') + describedState('ljStateErrorRequired')); + + for (const part of ['rect.outer', '.divider']) { + expect(stroke(`.ljStateErrorFound ${part}`).color).toBe('var(--vscode-errorForeground)'); + expect(stroke(`.ljStateErrorRequired ${part}`).color).toBe('var(--lj-state-required)'); + } + }); + + it('dashes the found condition and the possible found states', () => { + render(plainState('ljStateErrorFoundCondition') + describedState('ljStateErrorPossible')); + + expect(stroke('.ljStateErrorFoundCondition .label-container')).toEqual({ color: 'var(--vscode-errorForeground)', dash: '5 3' }); + expect(stroke('.ljStateErrorPossible rect.outer').dash).toBe('5 3'); + }); + + it('keeps unrelated states with the default outline', () => { + render(describedState('') + plainState('')); + + expect(stroke('rect.outer').color).toBe('var(--vscode-foreground)'); + expect(stroke('.label-container').color).toBe('var(--vscode-foreground)'); + }); +}); diff --git a/client/src/webview/styles.ts b/client/src/webview/styles.ts index d07ebeb..b39f45b 100644 --- a/client/src/webview/styles.ts +++ b/client/src/webview/styles.ts @@ -1017,18 +1017,17 @@ export function getStyles(): string { fill: var(--vscode-errorForeground) !important; stroke: var(--vscode-errorForeground) !important; } - .diagram-container .mermaid svg .ljStateErrorFound .label-container, - .diagram-container .mermaid svg .ljStateErrorFound .divider, - .diagram-container .mermaid svg .ljStateErrorFoundCondition .label-container { + /* states without a description render a .label-container, states with one an outer rect and a divider */ + .diagram-container .mermaid svg .ljStateErrorFound :is(.label-container, rect.outer, .divider), + .diagram-container .mermaid svg .ljStateErrorFoundCondition :is(.label-container, rect.outer, .divider) { stroke: var(--vscode-errorForeground) !important; stroke-width: 2px !important; } - .diagram-container .mermaid svg .ljStateErrorFoundCondition .label-container, - .diagram-container .mermaid svg .ljStateErrorPossible .label-container { + .diagram-container .mermaid svg .ljStateErrorFoundCondition :is(.label-container, rect.outer), + .diagram-container .mermaid svg .ljStateErrorPossible :is(.label-container, rect.outer) { stroke-dasharray: 5 3; } - .diagram-container .mermaid svg .ljStateErrorRequired .label-container, - .diagram-container .mermaid svg .ljStateErrorRequired .divider { + .diagram-container .mermaid svg .ljStateErrorRequired :is(.label-container, rect.outer, .divider) { stroke: var(--lj-state-required) !important; stroke-width: 2px !important; } From ebea31161540aa283e2db3d769bf23803bd575b8 Mon Sep 17 00:00:00 2001 From: Catarina Gamboa Date: Wed, 21 Oct 2026 00:02:32 +0100 Subject: [PATCH 3/4] Refine the focused state error view after review - Give a state that may be found and is also required one combined label and style instead of duplicate descriptions. - Keep self loops of the called method on required states in the focused view, since they show where the call is allowed. - Keep the called method highlight in the full diagram; the focused view shows the allowed edge without it. - Let the pan handler ignore the error summary so its text stays selectable. - Enable condition expansion only for the transitions actually displayed. Co-Authored-By: Claude Opus 5.5 --- client/src/webview/diagram-focus.spec.ts | 62 +++++++++++++++++--- client/src/webview/diagram-focus.ts | 34 +++++++++++ client/src/webview/diagram.ts | 46 ++++++++------- client/src/webview/script.spec.ts | 73 +++++++++++++++++++----- client/src/webview/styles.spec.ts | 7 +++ client/src/webview/styles.ts | 6 +- client/src/webview/views/fsm/fsm.spec.ts | 19 ++++++ client/src/webview/views/fsm/fsm.ts | 12 ++-- 8 files changed, 206 insertions(+), 53 deletions(-) diff --git a/client/src/webview/diagram-focus.spec.ts b/client/src/webview/diagram-focus.spec.ts index aeae765..f9d8223 100644 --- a/client/src/webview/diagram-focus.spec.ts +++ b/client/src/webview/diagram-focus.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { LJStateMachine, LJStateMachineErrorContext } from '../types/fsm'; import { createMermaidDiagram } from './diagram'; -import { getFocusedStates, getRejectedCallText, getStateErrorFocus } from './diagram-focus'; +import { getDisplayedStateMachine, getFocusedStates, getRejectedCallText, getStateErrorFocus } from './diagram-focus'; // DefaultMutableTreeNode from the state error focus issue (#117): // root.add(...); root.removeAllChildren(); root.getFirstChild(); // state error @@ -96,23 +96,23 @@ describe('state error focus', () => { 'childrenAllowed --> maybeHasChildren : add', 'maybeHasChildren --> childrenAllowed : removeAllChildren', ])); - // the rejected call is not drawn as a transition, and unrelated content is left out - expect(diagram.filter(line => line.includes('-->') && line.includes('getFirstChild'))).toEqual([]); + // the call only appears where it is allowed, without the highlight of a taken transition + expect(diagram.filter(line => line.includes('-->') && line.includes('getFirstChild'))) + .toEqual(['maybeHasChildren --> maybeHasChildren : getFirstChild']); expect(diagram.some(line => line.startsWith('[*]'))).toBe(false); expect(diagram.join('\n')).not.toContain('lj-state-error-method'); }); - it('marks the error on the full diagram without highlighting transitions by label', () => { + it('marks the error on the full diagram and highlights where the call is allowed', () => { const diagram = lines(createMermaidDiagram(treeNode(), 'TB', false, false)); expect(diagram).toEqual(expect.arrayContaining([ '[*] --> childrenAllowed : DefaultMutableTreeNode(Object)', - 'maybeHasChildren --> maybeHasChildren : getFirstChild', + 'maybeHasChildren --> maybeHasChildren : getFirstChild', 'note right of childrenAllowed : ⊘ root.getFirstChild()
Call rejected from this state', 'class childrenAllowed ljStateErrorFound', 'class maybeHasChildren ljStateErrorRequired', ])); - expect(diagram.join('\n')).not.toContain('lj-state-error-method'); expect(diagram.join('\n')).not.toContain('ljStateErrorActual'); }); @@ -129,8 +129,11 @@ describe('state error focus', () => { 'note right of bound : ⊘ socket.sendUrgentData()
Call rejected from this state', 'bound --> connected : connect', 'connected --> inputShutdown : shutdownInput', + // self loops of the called method show where it is allowed, other self loops are left out + 'connected --> connected : sendUrgentData', + 'inputShutdown --> inputShutdown : sendUrgentData', ])); - for (const unrelated of ['unconnected', 'outputShutdown', 'bothShutdown', 'closed', '[*]', 'isClosed', 'sendUrgentData :']) { + for (const unrelated of ['unconnected', 'outputShutdown', 'bothShutdown', 'closed', '[*]', 'isClosed']) { expect(focused).not.toContain(unrelated); } @@ -203,9 +206,50 @@ describe('state error focus', () => { sm.transitions.push({ from: 'childrenAllowed', to: 'maybeHasChildren', label: 'getFirstChild' }); const diagram = createMermaidDiagram(sm, 'TB', false, false); - expect(diagram).toContain('childrenAllowed --> maybeHasChildren : add
getFirstChild'); + expect(diagram).toContain('childrenAllowed --> maybeHasChildren : add
getFirstChild'); expect(diagram).toContain('note right of childrenAllowed : ⊘ root.getFirstChild()'); - expect(diagram).not.toContain('lj-state-error-method'); + }); + + it('combines the labels of a state that may be found and is also required', () => { + const sm: LJStateMachine = { + className: 'Connection', + states: ['Open', 'Closed'], + initialTransitions: [{ to: 'Closed' }], + transitions: [ + { from: 'Closed', to: 'Open', label: 'open' }, + { from: 'Open', to: 'Open', label: 'read' }, + ], + errorContext: { + calledMethod: 'read', actualStates: ['Open', 'Closed'], receiver: 'connection', expectedStates: ['Open'], + expectedCondition: 'Open(connection)', foundCondition: 'Open(connection) || Closed(connection)', + definiteFoundState: null, + }, + }; + + for (const focusOnError of [true, false]) { + const diagram = lines(createMermaidDiagram(sm, 'TB', false, focusOnError)); + expect(diagram.filter(line => line.startsWith('Open :'))).toEqual([ + 'Open : Open', + 'Open : ? Possible · ✓ Required
for this call', + ]); + expect(diagram.filter(line => line.startsWith('class'))).toEqual([ + 'class lj_found_condition ljStateErrorFoundCondition', + 'class Closed ljStateErrorPossible', + 'class Open ljStateErrorPossibleRequired', + ]); + } + // the call is only allowed as a self loop on the required state + expect(lines(createMermaidDiagram(sm, 'TB', false, true))).toContain('Open --> Open : read'); + }); + + it('only offers condition expansions of the displayed transitions', () => { + const sm = socket(); + sm.transitions.push({ from: 'closed', to: 'closed', label: 'isBound', fromCondition: 'size > 0' }); + + expect(getDisplayedStateMachine(sm, false).transitions.some(transition => transition.fromCondition)).toBe(true); + const focused = getDisplayedStateMachine(sm, true); + expect(focused.initialTransitions).toEqual([]); + expect(focused.transitions.some(transition => transition.fromCondition)).toBe(false); }); it('keeps the previous highlighting for an error context without the error states', () => { diff --git a/client/src/webview/diagram-focus.ts b/client/src/webview/diagram-focus.ts index dde3fd8..abd0069 100644 --- a/client/src/webview/diagram-focus.ts +++ b/client/src/webview/diagram-focus.ts @@ -11,6 +11,7 @@ export const REJECTED_CALL_ICON = '\u2298'; export const REJECTED_CALL_TEXT = 'Call rejected from this state'; export const REJECTED_CALL_UNKNOWN_STATE_TEXT = 'Call rejected from the found state'; export const UNKNOWN_FOUND_STATE_TEXT = 'no single state'; +export const POSSIBLE_REQUIRED_STATE_TEXT = `Possible \u00B7 ${REQUIRED_STATE_ICON} ${REQUIRED_STATE_TEXT}`; /** * The selected state error of a single state machine, with the states it involves @@ -84,6 +85,39 @@ function getShortestPath(sm: LJStateMachine, sources: string[], targets: string[ return []; } +/** + * The part of a state machine shown in its diagram + */ +export type DisplayedStateMachine = { + errorFocus: StateErrorFocus | null; + focusedStates: string[] | null; + initialTransitions: LJStateMachine['initialTransitions']; + transitions: LJStateMachine['transitions']; +}; + +/** + * Gets the part of a state machine shown in its diagram + * The focused view only keeps the transitions between the focused states, without self loops + * except for the called method on a required state, which shows where the call is allowed + * @param sm + * @param focusOnError whether to only show the states involved in the selected state error + * @returns the error focus, the focused states (null for the full diagram), and the displayed transitions + */ +export function getDisplayedStateMachine(sm: LJStateMachine, focusOnError: boolean): DisplayedStateMachine { + const errorFocus = getStateErrorFocus(sm); + if (!focusOnError || !errorFocus) { + return { errorFocus, focusedStates: null, initialTransitions: sm.initialTransitions, transitions: sm.transitions }; + } + + const focusedStates = getFocusedStates(sm, errorFocus); + const transitions = sm.transitions.filter(transition => { + if (!focusedStates.includes(transition.from) || !focusedStates.includes(transition.to)) return false; + return transition.from !== transition.to + || (errorFocus.requiredStates.includes(transition.from) && transition.label === errorFocus.calledMethod); + }); + return { errorFocus, focusedStates, initialTransitions: [], transitions }; +} + /** * Gets the text shown for the rejected call, e.g. root.getFirstChild() * @param focus diff --git a/client/src/webview/diagram.ts b/client/src/webview/diagram.ts index 7826b1d..0ed425e 100644 --- a/client/src/webview/diagram.ts +++ b/client/src/webview/diagram.ts @@ -4,7 +4,7 @@ import { FOUND_STATE_ICON, FOUND_STATE_TEXT, POSSIBLE_STATE_ICON, POSSIBLE_STATE_TEXT, REJECTED_CALL_ICON, REJECTED_CALL_TEXT, REJECTED_CALL_UNKNOWN_STATE_TEXT, REQUIRED_STATE_ICON, REQUIRED_STATE_TEXT, UNKNOWN_FOUND_STATE_TEXT, - getFocusedStates, getRejectedCallText, getStateErrorFocus, type StateErrorFocus, + POSSIBLE_REQUIRED_STATE_TEXT, getDisplayedStateMachine, getRejectedCallText, type StateErrorFocus, } from "./diagram-focus"; // constants @@ -41,8 +41,7 @@ export function createMermaidDiagram( if (!sm) return ''; const lines: string[] = []; - const errorFocus = getStateErrorFocus(sm); - const focusedStates = focusOnError && errorFocus ? getFocusedStates(sm, errorFocus) : null; + const { errorFocus, focusedStates, initialTransitions, transitions } = getDisplayedStateMachine(sm, focusOnError); lines.push('stateDiagram-v2'); lines.push(` direction ${orientation}`); @@ -51,21 +50,16 @@ export function createMermaidDiagram( if (errorFocus && focusedStates) lines.push(...getStateErrorLines(errorFocus)); // initial transitions - if (!focusedStates) { - sm.initialTransitions.forEach(transition => { - const label = getInitialTransitionLabel(transition.constructorSignature, transition.toCondition, showConditions); - lines.push(` [*] --> ${transition.to}${label ? ` : ${label}` : ''}`); - }); - } + initialTransitions.forEach(transition => { + const label = getInitialTransitionLabel(transition.constructorSignature, transition.toCondition, showConditions); + lines.push(` [*] --> ${transition.to}${label ? ` : ${label}` : ''}`); + }); // group transitions by from/to states and merge labels const transitionMap = new Map(); - sm.transitions.forEach(transition => { - // the focused view only keeps the transitions between its states, without self loops - if (focusedStates && (transition.from === transition.to - || !focusedStates.includes(transition.from) || !focusedStates.includes(transition.to))) return; - // without the error states, the called method can only be found by its transition label - const isCalledMethod = !errorFocus && transition.label === sm.errorContext?.calledMethod; + transitions.forEach(transition => { + // the full diagram highlights where the called method is allowed, the focused view only marks the rejected call + const isCalledMethod = !focusedStates && transition.label === sm.errorContext?.calledMethod; const label = getTransitionLabel( transition.label, transition.fromCondition, @@ -107,6 +101,10 @@ export function createMermaidDiagram( function getStateErrorLines(focus: StateErrorFocus): string[] { const lines: string[] = []; const foundNode = focus.foundState ?? STATE_ERROR_FOUND_CONDITION_ID; + // a state that may be found and is also required gets a single combined label + const possibleRequiredStates = focus.possibleStates.filter(state => focus.requiredStates.includes(state)); + const possibleStates = focus.possibleStates.filter(state => !possibleRequiredStates.includes(state)); + const requiredStates = focus.requiredStates.filter(state => !possibleRequiredStates.includes(state)); if (focus.foundState) { lines.push(...getStateDescriptionLines(focus.foundState, `${FOUND_STATE_ICON} ${FOUND_STATE_TEXT}`)); } else { @@ -115,11 +113,14 @@ function getStateErrorLines(focus: StateErrorFocus): string[] { ? escapeMermaidLabel(focus.foundCondition).replace(/\\"/g, '#quot;') : UNKNOWN_FOUND_STATE_TEXT; lines.push(` state "${FOUND_STATE_ICON} Found: ${condition}" as ${STATE_ERROR_FOUND_CONDITION_ID}`); - focus.possibleStates.forEach(state => { + possibleStates.forEach(state => { lines.push(...getStateDescriptionLines(state, `${POSSIBLE_STATE_ICON} ${POSSIBLE_STATE_TEXT}`)); }); + possibleRequiredStates.forEach(state => { + lines.push(...getStateDescriptionLines(state, `${POSSIBLE_STATE_ICON} ${POSSIBLE_REQUIRED_STATE_TEXT}`)); + }); } - focus.requiredStates.forEach(state => { + requiredStates.forEach(state => { lines.push(...getStateDescriptionLines(state, `${REQUIRED_STATE_ICON} ${REQUIRED_STATE_TEXT}`)); }); @@ -129,12 +130,11 @@ function getStateErrorLines(focus: StateErrorFocus): string[] { lines.push(` note right of ${foundNode} : ${callLabel}`); lines.push(` class ${foundNode} ${focus.foundState ? 'ljStateErrorFound' : 'ljStateErrorFoundCondition'}`); - if (focus.possibleStates.length > 0 && !focus.foundState) { - lines.push(` class ${focus.possibleStates.join(',')} ljStateErrorPossible`); - } - if (focus.requiredStates.length > 0) { - lines.push(` class ${focus.requiredStates.join(',')} ljStateErrorRequired`); + if (possibleStates.length > 0) lines.push(` class ${possibleStates.join(',')} ljStateErrorPossible`); + if (possibleRequiredStates.length > 0) { + lines.push(` class ${possibleRequiredStates.join(',')} ljStateErrorPossibleRequired`); } + if (requiredStates.length > 0) lines.push(` class ${requiredStates.join(',')} ljStateErrorRequired`); return lines; } @@ -323,6 +323,8 @@ export function registerPanListeners(document: any) { const onMouseDown = (e: any) => { const target = e.target as any; if (target.tagName === 'A' || target.tagName === 'BUTTON') return; + // keep the error summary text selectable + if (target.closest?.('.diagram-error-summary')) return; e.preventDefault(); isPanning = true; diff --git a/client/src/webview/script.spec.ts b/client/src/webview/script.spec.ts index 2857b3c..61fa113 100644 --- a/client/src/webview/script.spec.ts +++ b/client/src/webview/script.spec.ts @@ -57,6 +57,29 @@ const stateMachine: LJStateMachine = { errorContext: null, }; +function stateError(title: string, sm: LJStateMachine, line = position.lineStart): LJDiagnostic { + const errorPosition = { ...position, lineStart: line, lineEnd: line }; + return { + title, message: `${title} message`, hint: null, file: currentFile, position: errorPosition, + category: 'error', type: 'state-refinement-error', translationTable: {}, expected: 'Open(counter)', + found: { implication: null as never, origin: null, simplification: 'Closed(counter)' }, + customMessage: '', declarationPosition: null, stateMachine: sm, + }; +} + +function errorMachine(className: string, calledMethod: string): LJStateMachine { + return { + ...stateMachine, + className, + transitions: [...stateMachine.transitions, { from: 'Closed', to: 'Closed', label: 'reopen()' }], + initialTransitions: [{ to: 'Open' }, { to: 'Closed' }], + errorContext: { + calledMethod, actualStates: ['Closed'], receiver: 'counter', expectedStates: ['Open'], + expectedCondition: 'Open(counter)', foundCondition: 'Closed(counter)', definiteFoundState: 'Closed', + }, + }; +} + describe('webview script', () => { let dom: JSDOM; let root: HTMLElement; @@ -142,21 +165,7 @@ describe('webview script', () => { }); it('opens a state error focused on the error and toggles the full diagram', () => { - const errorMachine: LJStateMachine = { - ...stateMachine, - transitions: [...stateMachine.transitions, { from: 'Closed', to: 'Closed', label: 'reopen()' }], - initialTransitions: [{ to: 'Open' }, { to: 'Closed' }], - errorContext: { - calledMethod: 'close', actualStates: ['Closed'], receiver: 'counter', expectedStates: ['Open'], - expectedCondition: 'Open(counter)', foundCondition: 'Closed(counter)', definiteFoundState: 'Closed', - }, - }; - const error: LJDiagnostic = { - title: 'State refinement error', message: 'State refinement error message', hint: null, - file: currentFile, position, category: 'error', type: 'state-refinement-error', translationTable: {}, - expected: 'Open(counter)', found: { implication: null as never, origin: null, simplification: 'Closed(counter)' }, - customMessage: '', declarationPosition: null, stateMachine: errorMachine, - }; + const error = stateError('State refinement error', errorMachine('Counter', 'close')); showDiagnostics([error]); click('.diagnostic-state-machine-btn'); @@ -183,6 +192,40 @@ describe('webview script', () => { expect(root.querySelector('.mermaid')?.textContent).not.toContain('note'); }); + it('opens another state error focused after showing the full diagram of the previous one', () => { + showDiagnostics([ + stateError('First state error', errorMachine('First', 'close'), 4), + stateError('Second state error', errorMachine('Second', 'reset'), 9), + ]); + + click('.diagnostic-state-machine-btn[data-error-index="0"]'); + click('#diagram-focus-btn'); + expect(root.querySelector('#diagram-focus-btn')?.textContent?.trim()).toBe('Focus on error'); + + click('[data-tab="diagnostics"]'); + click('.diagnostic-state-machine-btn[data-error-index="1"]'); + expect(root.querySelector('.diagram-title')?.textContent).toBe('Second'); + expect(root.querySelector('#diagram-focus-btn')?.textContent?.trim()).toBe('Show full diagram'); + expect(root.querySelector('.mermaid')?.textContent).toContain('counter.reset()'); + expect(root.querySelector('.mermaid')?.textContent).not.toContain('[*]'); + }); + + it('keeps the error summary selectable while the diagram pans', async () => { + showDiagnostics([stateError('State refinement error', errorMachine('Counter', 'close'))]); + click('.diagnostic-state-machine-btn'); + await vi.waitFor(() => { + expect(root.querySelector('#diagram-wrapper')?.style.transform).toBe('matrix(1, 0, 0, 1, 0, 0)'); + }); + + const mouseDown = (selector: string) => { + const event = new dom.window.MouseEvent('mousedown', { bubbles: true, cancelable: true }); + root.querySelector(selector)!.dispatchEvent(event); + return event.defaultPrevented; + }; + expect(mouseDown('.diagram-error-found code')).toBe(false); + expect(mouseDown('#diagram-wrapper')).toBe(true); + }); + it('toggles between file and workspace errors by clicking scope buttons', () => { showDiagnostics([ diagnostic(currentFile, 'Current error'), diff --git a/client/src/webview/styles.spec.ts b/client/src/webview/styles.spec.ts index 156dda3..57f0178 100644 --- a/client/src/webview/styles.spec.ts +++ b/client/src/webview/styles.spec.ts @@ -46,6 +46,13 @@ describe('state error styles', () => { expect(stroke('.ljStateErrorPossible rect.outer').dash).toBe('5 3'); }); + it('dashes a possible state that is also required in the required colour', () => { + render(describedState('ljStateErrorPossibleRequired')); + + expect(stroke('.ljStateErrorPossibleRequired rect.outer')).toEqual({ color: 'var(--lj-state-required)', dash: '5 3' }); + expect(stroke('.ljStateErrorPossibleRequired .divider').color).toBe('var(--lj-state-required)'); + }); + it('keeps unrelated states with the default outline', () => { render(describedState('') + plainState('')); diff --git a/client/src/webview/styles.ts b/client/src/webview/styles.ts index b39f45b..5888bb6 100644 --- a/client/src/webview/styles.ts +++ b/client/src/webview/styles.ts @@ -1024,10 +1024,12 @@ export function getStyles(): string { stroke-width: 2px !important; } .diagram-container .mermaid svg .ljStateErrorFoundCondition :is(.label-container, rect.outer), - .diagram-container .mermaid svg .ljStateErrorPossible :is(.label-container, rect.outer) { + .diagram-container .mermaid svg .ljStateErrorPossible :is(.label-container, rect.outer), + .diagram-container .mermaid svg .ljStateErrorPossibleRequired :is(.label-container, rect.outer) { stroke-dasharray: 5 3; } - .diagram-container .mermaid svg .ljStateErrorRequired :is(.label-container, rect.outer, .divider) { + .diagram-container .mermaid svg .ljStateErrorRequired :is(.label-container, rect.outer, .divider), + .diagram-container .mermaid svg .ljStateErrorPossibleRequired :is(.label-container, rect.outer, .divider) { stroke: var(--lj-state-required) !important; stroke-width: 2px !important; } diff --git a/client/src/webview/views/fsm/fsm.spec.ts b/client/src/webview/views/fsm/fsm.spec.ts index b18f26e..df07d52 100644 --- a/client/src/webview/views/fsm/fsm.spec.ts +++ b/client/src/webview/views/fsm/fsm.spec.ts @@ -152,6 +152,25 @@ describe('state machine view', () => { expect(document.querySelector('#diagram-focus-btn')).toBeNull(); }); + it('only enables condition expansion for conditions shown in the focused view', () => { + const errorMachine = machine({ + transitions: [ + { from: 'Closed', to: 'Open', label: 'open' }, + { from: 'Closed', to: 'Closed', label: 'reset', fromCondition: 'size > 0' }, + ], + errorContext: { + calledMethod: 'read', actualStates: ['Closed'], receiver: 'connection', expectedStates: ['Open'], + expectedCondition: 'Open(connection)', foundCondition: 'Closed(connection)', definiteFoundState: 'Closed', + }, + }); + + renderStateMachineView(document.body, errorMachine, diagram, 'TB', false, true); + expect(button('diagram-conditions-btn').disabled).toBe(true); + + renderStateMachineView(document.body, errorMachine, diagram, 'TB', false, false); + expect(button('diagram-conditions-btn').disabled).toBe(false); + }); + it('preserves the previous diagram container height when rerendering', () => { renderStateMachineView(document.body, machine(), diagram, 'TB', false); const previousContainer = document.querySelector('.diagram-container')!; diff --git a/client/src/webview/views/fsm/fsm.ts b/client/src/webview/views/fsm/fsm.ts index 1ef47c8..7dd09d0 100644 --- a/client/src/webview/views/fsm/fsm.ts +++ b/client/src/webview/views/fsm/fsm.ts @@ -1,6 +1,6 @@ import type { LJStateMachine } from "../../../types/fsm"; import { - FOUND_STATE_ICON, REJECTED_CALL_ICON, REQUIRED_STATE_ICON, UNKNOWN_FOUND_STATE_TEXT, getRejectedCallText, getStateErrorFocus, type StateErrorFocus, + FOUND_STATE_ICON, REJECTED_CALL_ICON, REQUIRED_STATE_ICON, UNKNOWN_FOUND_STATE_TEXT, getDisplayedStateMachine, getRejectedCallText, type StateErrorFocus, } from "../../diagram-focus"; import { renderHighlightedInlineExpression } from "../../highlighting"; import { renderCodiconButton } from "../../icons"; @@ -14,10 +14,12 @@ export function renderStateMachineView(root: HTMLElement, sm: LJStateMachine | u } function renderStateMachineViewHtml(sm: LJStateMachine | undefined, diagram: string, orientation: "LR" | "TB", showConditions: boolean, focusOnError: boolean, diagramHeight?: number): string { - const errorFocus = getStateErrorFocus(sm); - const hasConditionExpansions = sm - ? sm.initialTransitions.some(transition => !!transition.toCondition) - || sm.transitions.some(transition => !!transition.fromCondition || !!transition.toCondition) + // only the displayed transitions can expand their conditions + const displayed = sm ? getDisplayedStateMachine(sm, focusOnError) : null; + const errorFocus = displayed?.errorFocus ?? null; + const hasConditionExpansions = displayed + ? displayed.initialTransitions.some(transition => !!transition.toCondition) + || displayed.transitions.some(transition => !!transition.fromCondition || !!transition.toCondition) : false; const conditionToggleLabel = !hasConditionExpansions ? 'No additional conditions to expand' From 9614034fdd75315d34d53596cc0be55d361e1fb9 Mon Sep 17 00:00:00 2001 From: Catarina Gamboa Date: Thu, 22 Oct 2026 00:02:48 +0100 Subject: [PATCH 4/4] Use liquidjava-fsm 0.1.4 for the failing call's error context Co-Authored-By: Claude Opus 5.5 --- server/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/pom.xml b/server/pom.xml index 514d511..53bef18 100644 --- a/server/pom.xml +++ b/server/pom.xml @@ -172,7 +172,7 @@ io.github.liquid-java liquidjava-fsm - 0.1.3 + 0.1.4 tools.aqua