From bb889b9983facafe35e3c6d696158cae599448b8 Mon Sep 17 00:00:00 2001 From: Catarina Gamboa Date: Tue, 20 Oct 2026 23:49:19 +0100 Subject: [PATCH 1/7] 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/7] 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/7] 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/7] 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 From 3f4f0be4327d81f6c28643bed5b7498f5d2943e3 Mon Sep 17 00:00:00 2001 From: Catarina Gamboa Date: Sat, 24 Oct 2026 02:28:23 +0100 Subject: [PATCH 5/7] Dim the unrelated parts of the state machine instead of reducing it Rework the state error view into a minimal overlay on the full diagram. Given the receiver's possible states and the states the call accepts, possible states that accept the call get a green outline and a check mark, possible states that reject it a red outline and a cross, and the other accepted states a thin dashed warning outline. Every transition of the called method keeps its red label. Focusing on the error dims the states and transitions unrelated to those states, and a single button toggles the dimming. A one line summary says where the call is rejected and where it is allowed. This removes the reduced diagram, the rejected call note, the found condition node, and the state descriptions. The possible states come from the new possibleStates field, falling back to the definite found state or the found states; error contexts without the accepted states keep the previous highlighting. Co-Authored-By: Claude Opus 5.5 --- client/src/types/fsm.ts | 1 + client/src/webview/diagram-focus.spec.ts | 300 ++++++++--------------- client/src/webview/diagram-focus.ts | 112 ++------- client/src/webview/diagram.ts | 140 ++++------- client/src/webview/script.spec.ts | 32 +-- client/src/webview/script.ts | 2 +- client/src/webview/styles.spec.ts | 57 +++-- client/src/webview/styles.ts | 62 ++--- client/src/webview/views/fsm/fsm.spec.ts | 49 ++-- client/src/webview/views/fsm/fsm.ts | 45 ++-- 10 files changed, 262 insertions(+), 538 deletions(-) diff --git a/client/src/types/fsm.ts b/client/src/types/fsm.ts index 1003955..2ed670a 100644 --- a/client/src/types/fsm.ts +++ b/client/src/types/fsm.ts @@ -16,4 +16,5 @@ export type LJStateMachineErrorContext = { expectedCondition?: string | null; foundCondition?: string | null; definiteFoundState?: string | null; + possibleStates?: string[]; }; diff --git a/client/src/webview/diagram-focus.spec.ts b/client/src/webview/diagram-focus.spec.ts index f9d8223..68b4b53 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 { getDisplayedStateMachine, getFocusedStates, getRejectedCallText, getStateErrorFocus } from './diagram-focus'; +import { getRelatedStates, getStateErrorFocus } from './diagram-focus'; // DefaultMutableTreeNode from the state error focus issue (#117): // root.add(...); root.removeAllChildren(); root.getFirstChild(); // state error @@ -20,43 +20,41 @@ function treeNode(errorContext: Partial | null = {}) actualStates: ['childrenAllowed'], receiver: 'root', expectedStates: ['maybeHasChildren'], - expectedCondition: 'maybeHasChildren(root)', - foundCondition: 'childrenAllowed(root)', definiteFoundState: 'childrenAllowed', + possibleStates: ['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' })), - ]; +// java.net.DatagramSocket from the UDP chat client study example (#158): socket.close() on one branch, +// then socket.receive(...), with an extra state unrelated to the error +function datagramSocket(errorContext: Partial = {}): LJStateMachine { + const open = ['unbound', 'bound', 'connected']; return { - className: 'Socket', - states, - initialTransitions: [{ to: 'unconnected', constructorSignature: 'Socket()' }], - transitions, + className: 'java.net.DatagramSocket', + states: ['unbound', 'bound', 'connected', 'closed', 'broken'], + initialTransitions: [{ to: 'bound', constructorSignature: 'new DatagramSocket()' }, { to: 'broken' }], + transitions: [ + { from: 'unbound', to: 'bound', label: 'bind' }, + { from: 'bound', to: 'connected', label: 'connect' }, + { from: 'connected', to: 'bound', label: 'disconnect' }, + ...open.map(state => ({ from: state, to: state, label: 'receive' })), + ...open.map(state => ({ from: state, to: state, label: 'send' })), + ...open.map(state => ({ from: state, to: 'closed', label: 'close' })), + { from: 'broken', to: 'broken', label: 'repair' }, + { from: 'broken', to: 'closed', label: 'discard' }, + ], errorContext: { - calledMethod: 'sendUrgentData', - actualStates: ['bound'], + calledMethod: 'receive', + actualStates: ['bound', 'closed'], receiver: 'socket', - expectedStates: ['connected', 'inputShutdown'], - expectedCondition: 'connected(socket) || inputShutdown(socket)', - foundCondition: 'bound(socket)', - definiteFoundState: 'bound', + expectedStates: open, + expectedCondition: 'unbound(socket) || bound(socket) || connected(socket)', + foundCondition: 'flag ? closed(socket) : bound(socket)', + definiteFoundState: null, + possibleStates: ['bound', 'closed'], + ...errorContext, }, }; } @@ -65,210 +63,106 @@ 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()); +const dimmed = (label: string) => `${label}`; +const calledMethod = (label: string) => `${label}`; - expect(focus).toEqual({ - calledMethod: 'getFirstChild', - receiver: 'root', - foundState: 'childrenAllowed', - possibleStates: [], - requiredStates: ['maybeHasChildren'], - foundCondition: 'childrenAllowed(root)', - expectedCondition: 'maybeHasChildren(root)', +describe('state error focus', () => { + it('splits the possible states into valid and invalid ones', () => { + expect(getStateErrorFocus(datagramSocket())).toEqual({ + calledMethod: 'receive', + receiver: 'socket', + validStates: ['bound'], + invalidStates: ['closed'], + acceptedStates: ['unbound', 'bound', 'connected'], }); - 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 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 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', - 'note right of childrenAllowed : ⊘ root.getFirstChild()
Call rejected from this state', - 'class childrenAllowed ljStateErrorFound', - 'class maybeHasChildren ljStateErrorRequired', - ])); - 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', - // 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']) { - 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'); + expect(getRelatedStates(getStateErrorFocus(datagramSocket())!)).toEqual(['bound', 'closed', 'unbound', 'connected']); }); - it('escapes quotes in a found condition shown as a state name', () => { - const sm = treeNode({ foundCondition: 'name == "x"', definiteFoundState: null }); + it.each([ + ['the definite found state', { possibleStates: undefined, definiteFoundState: 'closed' }, [], ['closed']], + ['the found states', { possibleStates: undefined, definiteFoundState: null }, ['bound'], ['closed']], + ])('falls back to %s without possible states', (_, errorContext, validStates, invalidStates) => { + const focus = getStateErrorFocus(datagramSocket(errorContext)); - expect(createMermaidDiagram(sm, 'TB', false, true)).toContain('state "◉ Found: name == #quot;x#quot;" as lj_found_condition'); + expect(focus?.validStates).toEqual(validStates); + expect(focus?.invalidStates).toEqual(invalidStates); }); - 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'); - + it('marks valid and invalid states and colours every edge of the called method', () => { for (const focusOnError of [true, false]) { - expect(createMermaidDiagram(sm, 'TB', false, focusOnError)) - .toContain('note right of childrenAllowed : ⊘ root.getFirstChild()
Call rejected from this state'); + const diagram = lines(createMermaidDiagram(datagramSocket(), 'TB', false, focusOnError)); + + expect(diagram).toEqual(expect.arrayContaining([ + 'state "✓ bound" as bound', + 'state "✕ closed" as closed', + 'class bound ljStateValid', + 'class closed ljStateInvalid', + `unbound --> unbound : ${calledMethod('receive')}
send`, + `bound --> bound : ${calledMethod('receive')}
send`, + `connected --> connected : ${calledMethod('receive')}
send`, + ])); + // accepted states that are not possible get only a class, without a mark in their name + expect(diagram.filter(line => /^(state|class) .*\b(unbound|connected)\b/.test(line))) + .toEqual(['class unbound,connected ljStateAccepted']); + expect(diagram.join('\n')).not.toContain('ljStateErrorActual'); } }); - it('shows the rejected call separately from a merged transition label', () => { - const sm = treeNode(); - sm.transitions.push({ from: 'childrenAllowed', to: 'maybeHasChildren', label: 'getFirstChild' }); + it('dims the states and transitions unrelated to the error when focused', () => { + const focused = lines(createMermaidDiagram(datagramSocket(), 'TB', false, true)); + + expect(focused).toEqual(expect.arrayContaining([ + 'class broken ljStateDimmed', + `[*] --> broken : ${dimmed('')}`, + `broken --> broken : ${dimmed('repair')}`, + `broken --> closed : ${dimmed('discard')}`, + // transitions between related states, and initial transitions into them, stay + '[*] --> bound : new DatagramSocket()', + 'unbound --> bound : bind', + 'bound --> closed : close', + ])); + expect(focused.filter(line => line.includes('lj-dimmed-transition'))).toHaveLength(3); - const diagram = createMermaidDiagram(sm, 'TB', false, false); - expect(diagram).toContain('childrenAllowed --> maybeHasChildren : add
getFirstChild'); - expect(diagram).toContain('note right of childrenAllowed : ⊘ root.getFirstChild()'); + const full = createMermaidDiagram(datagramSocket(), 'TB', false, false); + expect(full).not.toContain('ljStateDimmed'); + expect(full).not.toContain('lj-dimmed-transition'); + expect(lines(full)).toContain('[*] --> broken'); }); - 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, - }, - }; + it('keeps edges of the called method undimmed even between unrelated states', () => { + const sm = datagramSocket(); + sm.transitions.push({ from: 'broken', to: 'broken', label: 'receive' }); - 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'); + expect(lines(createMermaidDiagram(sm, 'TB', false, true))) + .toContain(`broken --> broken : repair
${calledMethod('receive')}`); }); - it('only offers condition expansions of the displayed transitions', () => { - const sm = socket(); - sm.transitions.push({ from: 'closed', to: 'closed', label: 'isBound', fromCondition: 'size > 0' }); + it('marks the tree node example without an allowed state', () => { + const diagram = lines(createMermaidDiagram(treeNode(), 'TB', false, true)); - 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); + expect(diagram).toEqual(expect.arrayContaining([ + 'state "✕ childrenAllowed" as childrenAllowed', + 'class childrenAllowed ljStateInvalid', + 'class maybeHasChildren ljStateAccepted', + `maybeHasChildren --> maybeHasChildren : ${calledMethod('getFirstChild')}`, + ])); + expect(diagram.join('\n')).not.toMatch(/maybeHasChildren" as|ljStateValid|ljStateDimmed|lj-dimmed-transition/); }); - it('keeps the previous highlighting for an error context without the error states', () => { + it('keeps the previous highlighting for an error context without the accepted 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(`maybeHasChildren --> maybeHasChildren : ${calledMethod('getFirstChild')}`); expect(diagram).toContain('class childrenAllowed ljStateErrorActual'); - expect(diagram).not.toContain('note'); + expect(diagram).not.toMatch(/ljStateValid|ljStateInvalid|ljStateAccepted|ljStateDimmed|" as /); }); it('leaves the general diagram unchanged without an error', () => { - const sm = treeNode(null); - - expect(getStateErrorFocus(sm)).toBeNull(); - expect(createMermaidDiagram(sm, 'TB', false, true)).toBe([ + expect(createMermaidDiagram(treeNode(null), 'TB', false, true)).toBe([ 'stateDiagram-v2', ' direction TB', ' [*] --> childrenAllowed : DefaultMutableTreeNode(Object)', diff --git a/client/src/webview/diagram-focus.ts b/client/src/webview/diagram-focus.ts index abd0069..46c15a4 100644 --- a/client/src/webview/diagram-focus.ts +++ b/client/src/webview/diagram-focus.ts @@ -1,121 +1,51 @@ 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'; -export const POSSIBLE_REQUIRED_STATE_TEXT = `Possible \u00B7 ${REQUIRED_STATE_ICON} ${REQUIRED_STATE_TEXT}`; +export const VALID_STATE_ICON = '✓'; +export const INVALID_STATE_ICON = '✕'; /** - * The selected state error of a single state machine, with the states it involves + * The selected state error of a single state machine, split by the possible states of the receiver */ export type StateErrorFocus = { calledMethod: string | null; receiver: string | null; - foundState: string | null; - possibleStates: string[]; - requiredStates: string[]; - foundCondition: string | null; - expectedCondition: string | null; + // possible states from which the call is accepted + validStates: string[]; + // possible states from which the call is rejected + invalidStates: string[]; + // states from which the call is accepted + acceptedStates: string[]; }; /** * 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 + * @returns the state error focus, or null when there is no error or the server does not report the accepted 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; + const possible = context.possibleStates + ?? (context.definiteFoundState ? [context.definiteFoundState] : context.actualStates); + const possibleStates = sm.states.filter(state => possible.includes(state)); + const acceptedStates = sm.states.filter(state => context.expectedStates?.includes(state)); 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, + validStates: possibleStates.filter(state => acceptedStates.includes(state)), + invalidStates: possibleStates.filter(state => !acceptedStates.includes(state)), + acceptedStates, }; } /** - * 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 + * Gets the states related to a state error: the possible states of the receiver and the accepted states * @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 []; -} - -/** - * The part of a state machine shown in its diagram + * @returns the related states */ -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 }; +export function getRelatedStates(focus: StateErrorFocus): string[] { + return [...new Set([...focus.validStates, ...focus.invalidStates, ...focus.acceptedStates])]; } /** diff --git a/client/src/webview/diagram.ts b/client/src/webview/diagram.ts index 0ed425e..eb32e13 100644 --- a/client/src/webview/diagram.ts +++ b/client/src/webview/diagram.ts @@ -1,11 +1,6 @@ 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, - POSSIBLE_REQUIRED_STATE_TEXT, getDisplayedStateMachine, getRejectedCallText, type StateErrorFocus, -} from "./diagram-focus"; +import { INVALID_STATE_ICON, VALID_STATE_ICON, getRelatedStates, getStateErrorFocus } from "./diagram-focus"; // constants const MIN_ZOOM = 0.2; @@ -13,8 +8,6 @@ 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; @@ -29,7 +22,7 @@ let startY = 0; * @param sm * @param orientation * @param showConditions - * @param focusOnError whether to only show the states involved in the selected state error + * @param focusOnError whether to dim the states and transitions unrelated to the selected state error * @returns Mermaid diagram string */ export function createMermaidDiagram( @@ -41,25 +34,24 @@ export function createMermaidDiagram( if (!sm) return ''; const lines: string[] = []; - const { errorFocus, focusedStates, initialTransitions, transitions } = getDisplayedStateMachine(sm, focusOnError); + const errorFocus = getStateErrorFocus(sm); + const relatedStates = focusOnError && errorFocus ? getRelatedStates(errorFocus) : null; + const isDimmed = (state: string) => !!relatedStates && !relatedStates.includes(state); 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 - initialTransitions.forEach(transition => { + sm.initialTransitions.forEach(transition => { const label = getInitialTransitionLabel(transition.constructorSignature, transition.toCondition, showConditions); - lines.push(` [*] --> ${transition.to}${label ? ` : ${label}` : ''}`); + const displayedLabel = isDimmed(transition.to) ? dimLabel(label) : label; + lines.push(` [*] --> ${transition.to}${displayedLabel ? ` : ${displayedLabel}` : ''}`); }); // group transitions by from/to states and merge labels - const transitionMap = new Map(); - 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 transitionMap = new Map(); + sm.transitions.forEach(transition => { + const isCalledMethod = transition.label === sm.errorContext?.calledMethod; const label = getTransitionLabel( transition.label, transition.fromCondition, @@ -68,89 +60,48 @@ export function createMermaidDiagram( isCalledMethod, ); const key = `${transition.from}|${transition.to}`; - if (!transitionMap.has(key)) transitionMap.set(key, []); - transitionMap.get(key)?.push(label); + if (!transitionMap.has(key)) transitionMap.set(key, { labels: [], hasCalledMethod: false }); + const group = transitionMap.get(key)!; + group.labels.push(label); + group.hasCalledMethod ||= isCalledMethod; }); // add transitions - transitionMap.forEach((labels, key) => { + transitionMap.forEach(({ labels, hasCalledMethod }, key) => { const [from, to] = key.split('|'); const mergedLabel = labels.join('
'); - lines.push(` ${from} --> ${to} : ${mergedLabel}`); + const dimmed = !hasCalledMethod && (isDimmed(from) || isDimmed(to)); + lines.push(` ${from} --> ${to} : ${dimmed ? dimLabel(mergedLabel) : mergedLabel}`); }); - if (errorFocus) { - if (!focusedStates) lines.push(...getStateErrorLines(errorFocus)); + if (!errorFocus) { + const highlightedStates = sm.states.filter(state => sm.errorContext?.actualStates.includes(state)); + if (highlightedStates.length > 0) { + lines.push(` class ${highlightedStates.join(',')} ljStateErrorActual`); + } 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`); - } - - 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; - // 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 { - // 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}`); - 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}`)); - }); - } - requiredStates.forEach(state => { - lines.push(...getStateDescriptionLines(state, `${REQUIRED_STATE_ICON} ${REQUIRED_STATE_TEXT}`)); + // mark the possible states from which the call is accepted or rejected, with text besides colour, + // and hint at the other states that accept the call + errorFocus.validStates.forEach(state => lines.push(` state "${VALID_STATE_ICON} ${state}" as ${state}`)); + errorFocus.invalidStates.forEach(state => lines.push(` state "${INVALID_STATE_ICON} ${state}" as ${state}`)); + const stateClasses: [string[], string][] = [ + [errorFocus.validStates, 'ljStateValid'], + [errorFocus.invalidStates, 'ljStateInvalid'], + [errorFocus.acceptedStates.filter(state => !errorFocus.validStates.includes(state)), 'ljStateAccepted'], + [sm.states.filter(isDimmed), 'ljStateDimmed'], + ]; + stateClasses.forEach(([states, className]) => { + if (states.length > 0) lines.push(` class ${states.join(',')} ${className}`); }); - 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 (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; -} - -function getStateDescriptionLines(state: string, description: string): string[] { - return [` ${state} : ${state}`, ` ${state} : ${wrapLabel(description)}`]; + return lines.join('\n'); } -// 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('
'); +// marks a transition label so that its transition is dimmed once rendered +function dimLabel(label: string): string { + return `${label}`; } function getTransitionLabel( @@ -205,6 +156,7 @@ export async function renderMermaidDiagram(document: any, window: any) { try { await mermaid.run({ nodes: mermaidElements }); highlightErrorTransitions(document); + dimTransitions(document); applyTransform(document); registerPanListeners(document); const diagramContainer = document.querySelector('.diagram-container') as HTMLElement | null; @@ -228,6 +180,18 @@ function highlightErrorTransitions(document: any) { }); } +function dimTransitions(document: any) { + document.querySelectorAll('.lj-dimmed-transition').forEach((dimmedLabel: any) => { + const label = dimmedLabel.closest('g.label[data-id]'); + const edgeId = label?.getAttribute('data-id'); + const svg = label?.ownerSVGElement; + if (!edgeId || !svg) return; + + label.classList.add('lj-dimmed'); + svg.querySelector(`path.transition[data-id="${edgeId}"]`)?.classList.add('lj-dimmed'); + }); +} + function highlightTransitionMarker(svg: any, transition: any, edgeId: string) { const markerReference = transition.getAttribute('marker-end'); const markerId = markerReference?.match(/^url\(#(.+)\)$/)?.[1]; diff --git a/client/src/webview/script.spec.ts b/client/src/webview/script.spec.ts index 61fa113..08c0ab6 100644 --- a/client/src/webview/script.spec.ts +++ b/client/src/webview/script.spec.ts @@ -71,8 +71,9 @@ function errorMachine(className: string, calledMethod: string): LJStateMachine { return { ...stateMachine, className, - transitions: [...stateMachine.transitions, { from: 'Closed', to: 'Closed', label: 'reopen()' }], - initialTransitions: [{ to: 'Open' }, { to: 'Closed' }], + states: [...stateMachine.states, 'Broken'], + transitions: [...stateMachine.transitions, { from: 'Broken', to: 'Broken', label: 'reopen()' }], + initialTransitions: [{ to: 'Open' }, { to: 'Broken' }], errorContext: { calledMethod, actualStates: ['Closed'], receiver: 'counter', expectedStates: ['Open'], expectedCondition: 'Open(counter)', foundCondition: 'Closed(counter)', definiteFoundState: 'Closed', @@ -164,32 +165,31 @@ describe('webview script', () => { ]); }); - it('opens a state error focused on the error and toggles the full diagram', () => { + it('opens a state error focused on the error and toggles the dimming', () => { const error = stateError('State refinement error', errorMachine('Counter', 'close')); 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('[*]'); + expect(root.querySelector('#diagram-focus-btn')?.textContent?.trim()).toBe('Show all'); + expect(root.querySelector('.mermaid')?.textContent).toContain('class Broken ljStateDimmed'); + // the full diagram stays visible, only the dimming is toggled 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'); + expect(root.querySelector('.mermaid')?.textContent).toContain('Broken --> Broken : reopen()'); + expect(root.querySelector('.mermaid')?.textContent).not.toContain('ljStateDimmed'); click('#diagram-focus-btn'); - expect(root.querySelector('#diagram-focus-btn')?.textContent?.trim()).toBe('Show full diagram'); - expect(root.querySelector('.mermaid')?.textContent).not.toContain('[*]'); + expect(root.querySelector('#diagram-focus-btn')?.textContent?.trim()).toBe('Show all'); + expect(root.querySelector('.mermaid')?.textContent).toContain('class Broken ljStateDimmed'); // 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'); + expect(root.querySelector('.mermaid')?.textContent).not.toContain('ljState'); }); it('opens another state error focused after showing the full diagram of the previous one', () => { @@ -205,9 +205,9 @@ describe('webview script', () => { 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('[*]'); + expect(root.querySelector('#diagram-focus-btn')?.textContent?.trim()).toBe('Show all'); + expect(root.querySelector('.diagram-error-text')?.textContent).toContain('counter.reset()'); + expect(root.querySelector('.mermaid')?.textContent).toContain('class Broken ljStateDimmed'); }); it('keeps the error summary selectable while the diagram pans', async () => { @@ -222,7 +222,7 @@ describe('webview script', () => { root.querySelector(selector)!.dispatchEvent(event); return event.defaultPrevented; }; - expect(mouseDown('.diagram-error-found code')).toBe(false); + expect(mouseDown('.diagram-error-text code')).toBe(false); expect(mouseDown('#diagram-wrapper')).toBe(true); }); diff --git a/client/src/webview/script.ts b/client/src/webview/script.ts index 250b643..5b3c0d7 100644 --- a/client/src/webview/script.ts +++ b/client/src/webview/script.ts @@ -202,7 +202,7 @@ export function getScript(vscode: VSCodeApi, document: Document, window: Window) return; } - // toggle between the selected state error and the full diagram + // toggle the dimming of the diagram parts unrelated to the selected state error if (target.closest?.('#diagram-focus-btn')) { e.stopPropagation(); focusDiagramOnError = !focusDiagramOnError; diff --git a/client/src/webview/styles.spec.ts b/client/src/webview/styles.spec.ts index 57f0178..df25de0 100644 --- a/client/src/webview/styles.spec.ts +++ b/client/src/webview/styles.spec.ts @@ -1,13 +1,8 @@ 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*/` +// state markup copied from a Mermaid 11 state diagram rendered in the webview +const state = (className: string) => /*html*/` @@ -19,9 +14,8 @@ function render(nodes: string) {
${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') }; +function style(selector: string): CSSStyleDeclaration { + return getComputedStyle(document.querySelector(selector)!); } afterEach(() => { @@ -30,33 +24,38 @@ afterEach(() => { }); 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('outlines the states from which the call is rejected or accepted', () => { + render(state('ljStateInvalid') + state('ljStateValid')); + + expect(style('.ljStateInvalid .label-container').getPropertyValue('stroke')).toBe('var(--vscode-errorForeground)'); + expect(style('.ljStateValid .label-container').getPropertyValue('stroke')).toBe('var(--lj-state-valid)'); + // a faint fill of the outline colour helps to find small marked states in large machines + expect(style('.ljStateInvalid .label-container').getPropertyValue('fill')) + .toBe('color-mix(in srgb, var(--vscode-errorForeground) 10%, var(--vscode-editor-background))'); }); - it('dashes the found condition and the possible found states', () => { - render(plainState('ljStateErrorFoundCondition') + describedState('ljStateErrorPossible')); + it('hints at the other accepted states with a thin dashed warning outline', () => { + render(state('ljStateAccepted')); - expect(stroke('.ljStateErrorFoundCondition .label-container')).toEqual({ color: 'var(--vscode-errorForeground)', dash: '5 3' }); - expect(stroke('.ljStateErrorPossible rect.outer').dash).toBe('5 3'); + const outline = style('.ljStateAccepted .label-container'); + expect(outline.getPropertyValue('stroke')).toBe('var(--vscode-editorWarning-foreground, #cca700)'); + expect(outline.getPropertyValue('stroke-width')).toBe('1px'); + expect(outline.getPropertyValue('stroke-dasharray')).toBe('4 3'); + expect(outline.getPropertyValue('fill')).toBe('var(--vscode-editor-background)'); }); - it('dashes a possible state that is also required in the required colour', () => { - render(describedState('ljStateErrorPossibleRequired')); + it('dims unrelated states and transitions', () => { + render(state('ljStateDimmed') + ''); - expect(stroke('.ljStateErrorPossibleRequired rect.outer')).toEqual({ color: 'var(--lj-state-required)', dash: '5 3' }); - expect(stroke('.ljStateErrorPossibleRequired .divider').color).toBe('var(--lj-state-required)'); + for (const selector of ['.ljStateDimmed', 'path.lj-dimmed', 'g.lj-dimmed']) { + expect(style(selector).opacity).toBe('0.25'); + } }); - it('keeps unrelated states with the default outline', () => { - render(describedState('') + plainState('')); + it('keeps other states with the default outline', () => { + render(state('')); - expect(stroke('rect.outer').color).toBe('var(--vscode-foreground)'); - expect(stroke('.label-container').color).toBe('var(--vscode-foreground)'); + expect(style('.label-container').getPropertyValue('stroke')).toBe('var(--vscode-foreground)'); + expect(style('.statediagram-state').opacity).toBe('1'); }); }); diff --git a/client/src/webview/styles.ts b/client/src/webview/styles.ts index 5888bb6..c83b10d 100644 --- a/client/src/webview/styles.ts +++ b/client/src/webview/styles.ts @@ -42,7 +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)); + --lj-state-valid: var(--vscode-testing-iconPassed, var(--vscode-charts-green, #388a34)); } body.vscode-light { --lj-token-keyword: #0000FF; @@ -1017,39 +1017,29 @@ export function getStyles(): string { fill: var(--vscode-errorForeground) !important; stroke: var(--vscode-errorForeground) !important; } - /* 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) { + .diagram-container .mermaid svg .ljStateInvalid .label-container { + fill: color-mix(in srgb, var(--vscode-errorForeground) 10%, var(--vscode-editor-background)) !important; stroke: var(--vscode-errorForeground) !important; 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 .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 .ljStateErrorPossibleRequired :is(.label-container, rect.outer, .divider) { - stroke: var(--lj-state-required) !important; + .diagram-container .mermaid svg .ljStateValid .label-container { + fill: color-mix(in srgb, var(--lj-state-valid) 10%, var(--vscode-editor-background)) !important; + stroke: var(--lj-state-valid) !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; + .diagram-container .mermaid svg .ljStateAccepted .label-container { + stroke: var(--vscode-editorWarning-foreground, #cca700) !important; + stroke-width: 1px !important; stroke-dasharray: 4 3; } + .diagram-container .mermaid svg .ljStateDimmed, + .diagram-container .mermaid svg .lj-dimmed { + opacity: 0.25; + } .diagram-error-summary { display: flex; flex-wrap: wrap; - align-items: flex-start; + align-items: center; justify-content: space-between; gap: 0.5rem 1rem; margin-bottom: 0.75rem; @@ -1057,33 +1047,11 @@ export function getStyles(): string { cursor: default; user-select: text; } - .diagram-error-items { - display: flex; - flex-direction: column; - gap: 0.25rem; + .diagram-error-text { 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 df07d52..d83d132 100644 --- a/client/src/webview/views/fsm/fsm.spec.ts +++ b/client/src/webview/views/fsm/fsm.spec.ts @@ -109,42 +109,40 @@ describe('state machine view', () => { } }); - it('summarizes a selected state error with text and a toggle to the full diagram', () => { + it('summarizes where the call is rejected and allowed in one line', () => { const errorMachine = machine({ + states: ['unbound', 'bound', 'connected', 'closed'], errorContext: { - calledMethod: 'read', actualStates: ['Closed'], receiver: 'connection', - expectedStates: ['Open'], expectedCondition: 'Open(connection)', foundCondition: 'Closed(connection)', - definiteFoundState: 'Closed', + calledMethod: 'receive', actualStates: ['bound', 'closed'], receiver: 'socket', + expectedStates: ['unbound', 'bound', 'connected'], possibleStates: ['bound', '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'); + const summary = document.querySelector('.diagram-error-text')!; + expect(summary.textContent).toBe('socket.receive() is rejected when socket is closed \u00B7 allowed when bound'); + expect(Array.from(summary.querySelectorAll('code'), node => node.textContent)).toEqual(['socket.receive()', 'socket', 'closed', 'bound']); + expect(button('diagram-focus-btn').textContent?.trim()).toBe('Show all'); 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', () => { + it('omits the allowed part of the summary when no possible state accepts the call', () => { renderStateMachineView(document.body, machine({ + states: ['childrenAllowed', 'maybeHasChildren'], errorContext: { - calledMethod: 'read', actualStates: [], receiver: 'connection', expectedStates: ['Open'], - expectedCondition: 'Open(connection)', foundCondition: 'true', definiteFoundState: null, + calledMethod: 'getFirstChild', actualStates: ['childrenAllowed'], receiver: 'root', + expectedStates: ['maybeHasChildren'], definiteFoundState: 'childrenAllowed', }, }), diagram, 'TB', false, true); - expect(document.querySelector('.diagram-error-found')?.textContent?.replace(/\s+/g, ' ').trim()).toBe('\u25C9 Found (no single state)'); + expect(document.querySelector('.diagram-error-text')?.textContent).toBe('root.getFirstChild() is rejected when root is childrenAllowed'); }); it.each([ ['no error', null], - ['an error context without the error states', { calledMethod: 'open', actualStates: ['Closed'] }], + ['an error context without the accepted states', { calledMethod: 'open', actualStates: ['Closed'] }], ])('omits the error summary with %s', (_, errorContext) => { renderStateMachineView(document.body, machine({ errorContext }), diagram, 'TB', false, true); @@ -152,25 +150,6 @@ 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 7dd09d0..00ab827 100644 --- a/client/src/webview/views/fsm/fsm.ts +++ b/client/src/webview/views/fsm/fsm.ts @@ -1,8 +1,5 @@ import type { LJStateMachine } from "../../../types/fsm"; -import { - 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 { getRejectedCallText, getStateErrorFocus, type StateErrorFocus } from "../../diagram-focus"; import { renderCodiconButton } from "../../icons"; import { escapeHtml } from "../../utils"; import { renderMainHeader } from "../sections"; @@ -14,12 +11,10 @@ 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 { - // 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) + const errorFocus = getStateErrorFocus(sm); + const hasConditionExpansions = sm + ? sm.initialTransitions.some(transition => !!transition.toCondition) + || sm.transitions.some(transition => !!transition.fromCondition || !!transition.toCondition) : false; const conditionToggleLabel = !hasConditionExpansions ? 'No additional conditions to expand' @@ -61,28 +56,22 @@ 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); + const receiver = focus.receiver ? `${escapeHtml(focus.receiver)}` : 'the receiver'; + const parts = [ + `${call ? `${escapeHtml(call)}` : 'The call'} is rejected` + + (focus.invalidStates.length > 0 ? ` when ${receiver} is ${renderStates(focus.invalidStates)}` : ''), + focus.validStates.length > 0 ? `allowed when ${renderStates(focus.validStates)}` : '', + ]; return /*html*/`
-
    -
  • - - Call rejected${call ? /*html*/`: ${escapeHtml(call)}` : ''} -
  • -
  • - - Found${found ? /*html*/`: ${renderHighlightedInlineExpression(found)}` : ''}${foundNote} -
  • -
  • - - Required${required ? /*html*/`: ${renderHighlightedInlineExpression(required)}` : ''} -
  • -
+

${parts.filter(Boolean).join(' \u00B7 ')}

`; } + +function renderStates(states: string[]): string { + return states.map(state => `${escapeHtml(state)}`).join(' or '); +} From 49c0626662b15a58bf66030065a0b021c4df07e7 Mon Sep 17 00:00:00 2001 From: Catarina Gamboa Date: Sat, 24 Oct 2026 02:45:00 +0100 Subject: [PATCH 6/7] Fix edge cases of the state error overlay after review - Do not focus when the call accepts no state of the machine, fall back on empty possible states, and never dim when nothing is related. - When every state is possible, only hint at the accepted states and say that the state of the receiver is unknown. - Dim transitions after rendering from a Mermaid comment instead of wrapping their labels, so unlabeled initial transitions are dimmed without changing the layout between modes. - Keep the zoom and pan when toggling the dimming. - Copy the Mermaid source without the error overlay. - Phrase the summary when every possible state accepts the call. Co-Authored-By: Claude Opus 5.5 --- client/src/webview/diagram-focus.spec.ts | 102 +++++++++++++++++------ client/src/webview/diagram-focus.ts | 26 ++++-- client/src/webview/diagram.ts | 45 +++++----- client/src/webview/script.spec.ts | 36 ++++++++ client/src/webview/script.ts | 6 +- client/src/webview/views/fsm/fsm.spec.ts | 23 +++++ client/src/webview/views/fsm/fsm.ts | 18 ++-- 7 files changed, 196 insertions(+), 60 deletions(-) diff --git a/client/src/webview/diagram-focus.spec.ts b/client/src/webview/diagram-focus.spec.ts index 68b4b53..fd66d0e 100644 --- a/client/src/webview/diagram-focus.spec.ts +++ b/client/src/webview/diagram-focus.spec.ts @@ -1,9 +1,9 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import type { LJStateMachine, LJStateMachineErrorContext } from '../types/fsm'; -import { createMermaidDiagram } from './diagram'; +import { createMermaidDiagram, renderMermaidDiagram } from './diagram'; import { getRelatedStates, getStateErrorFocus } from './diagram-focus'; -// DefaultMutableTreeNode from the state error focus issue (#117): +// DefaultMutableTreeNode from the state error focus issue: // root.add(...); root.removeAllChildren(); root.getFirstChild(); // state error function treeNode(errorContext: Partial | null = {}): LJStateMachine { return { @@ -27,7 +27,7 @@ function treeNode(errorContext: Partial | null = {}) }; } -// java.net.DatagramSocket from the UDP chat client study example (#158): socket.close() on one branch, +// java.net.DatagramSocket from the UDP chat client study example: socket.close() on one branch, // then socket.receive(...), with an extra state unrelated to the error function datagramSocket(errorContext: Partial = {}): LJStateMachine { const open = ['unbound', 'bound', 'connected']; @@ -63,7 +63,7 @@ function lines(diagram: string): string[] { return diagram.split('\n').map(line => line.trim()); } -const dimmed = (label: string) => `${label}`; +const dimmedTransitions = (diagram: string) => lines(diagram).filter(line => line.startsWith('%% lj-dimmed-transitions:')); const calledMethod = (label: string) => `${label}`; describe('state error focus', () => { @@ -71,9 +71,11 @@ describe('state error focus', () => { expect(getStateErrorFocus(datagramSocket())).toEqual({ calledMethod: 'receive', receiver: 'socket', + possibleStates: ['bound', 'closed'], validStates: ['bound'], invalidStates: ['closed'], acceptedStates: ['unbound', 'bound', 'connected'], + unknownState: false, }); expect(getRelatedStates(getStateErrorFocus(datagramSocket())!)).toEqual(['bound', 'closed', 'unbound', 'connected']); }); @@ -108,33 +110,61 @@ describe('state error focus', () => { } }); - it('dims the states and transitions unrelated to the error when focused', () => { - const focused = lines(createMermaidDiagram(datagramSocket(), 'TB', false, true)); - - expect(focused).toEqual(expect.arrayContaining([ - 'class broken ljStateDimmed', - `[*] --> broken : ${dimmed('')}`, - `broken --> broken : ${dimmed('repair')}`, - `broken --> closed : ${dimmed('discard')}`, - // transitions between related states, and initial transitions into them, stay - '[*] --> bound : new DatagramSocket()', - 'unbound --> bound : bind', - 'bound --> closed : close', - ])); - expect(focused.filter(line => line.includes('lj-dimmed-transition'))).toHaveLength(3); - + it('dims the states and transitions unrelated to the error when focused, without changing the labels', () => { + const focused = createMermaidDiagram(datagramSocket(), 'TB', false, true); const full = createMermaidDiagram(datagramSocket(), 'TB', false, false); - expect(full).not.toContain('ljStateDimmed'); - expect(full).not.toContain('lj-dimmed-transition'); + + expect(lines(focused)).toContain('class broken ljStateDimmed'); + // the initial transition into broken, repair, and discard, in declaration order + expect(dimmedTransitions(focused)).toEqual(['%% lj-dimmed-transitions: edge1 edge11 edge12']); + // focusing only adds the dimming, so the layout stays the same + expect(lines(focused).filter(line => !line.startsWith('%%') && !line.endsWith('ljStateDimmed'))).toEqual(lines(full)); expect(lines(full)).toContain('[*] --> broken'); + expect(full).not.toContain('ljStateDimmed'); + expect(dimmedTransitions(full)).toEqual([]); }); it('keeps edges of the called method undimmed even between unrelated states', () => { const sm = datagramSocket(); sm.transitions.push({ from: 'broken', to: 'broken', label: 'receive' }); - expect(lines(createMermaidDiagram(sm, 'TB', false, true))) - .toContain(`broken --> broken : repair
${calledMethod('receive')}`); + const diagram = createMermaidDiagram(sm, 'TB', false, true); + expect(lines(diagram)).toContain(`broken --> broken : repair
${calledMethod('receive')}`); + expect(dimmedTransitions(diagram)).toEqual(['%% lj-dimmed-transitions: edge1 edge12']); + }); + + it('only colours labels that match the called method exactly', () => { + const sm = datagramSocket(); + sm.transitions.push({ from: 'bound', to: 'bound', label: 'receiveAll' }); + + expect(lines(createMermaidDiagram(sm, 'TB', false, true))).toContain(`bound --> bound : ${calledMethod('receive')}
send
receiveAll`); + }); + + it.each([ + ['no accepted states', { expectedStates: [], possibleStates: [] }], + ['accepted states outside the machine', { expectedStates: ['listening'] }], + ])('does not focus with %s', (_, errorContext) => { + const sm = datagramSocket(errorContext); + + expect(getStateErrorFocus(sm)).toBeNull(); + const diagram = createMermaidDiagram(sm, 'TB', false, true); + expect(diagram).not.toMatch(/ljStateDimmed|lj-dimmed-transitions|ljStateValid|ljStateInvalid/); + expect(diagram).toContain('class bound,closed ljStateErrorActual'); + }); + + it('ignores possible states outside the machine and falls back when none are given', () => { + expect(getStateErrorFocus(datagramSocket({ possibleStates: ['closed', 'listening'] }))?.possibleStates).toEqual(['closed']); + expect(getStateErrorFocus(datagramSocket({ possibleStates: [] }))?.possibleStates).toEqual(['bound', 'closed']); + }); + + it('only hints at the accepted states when the state of the receiver is unknown', () => { + const sm = datagramSocket({ possibleStates: ['unbound', 'bound', 'connected', 'closed', 'broken'], foundCondition: 'true' }); + + const focus = getStateErrorFocus(sm); + expect(focus).toMatchObject({ unknownState: true, validStates: [], invalidStates: [] }); + const diagram = createMermaidDiagram(sm, 'TB', false, true); + expect(lines(diagram)).toContain('class unbound,bound,connected ljStateAccepted'); + expect(diagram).not.toMatch(/ljStateValid|ljStateInvalid|ljStateDimmed|lj-dimmed-transitions|" as /); }); it('marks the tree node example without an allowed state', () => { @@ -172,3 +202,27 @@ describe('state error focus', () => { ].join('\n')); }); }); + +describe('rendered state error focus', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('dims the rendered path and label of the listed transitions', async () => { + const diagram = createMermaidDiagram(datagramSocket(), 'TB', false, true); + document.body.innerHTML = '
'; + const pre = document.querySelector('.mermaid')!; + pre.textContent = diagram; + // edge markup copied from a Mermaid 11 state diagram rendered with the ELK layout + const run = vi.fn(async () => { + pre.innerHTML = '' + ['edge0', 'edge1', 'edge12'].map(id => + ``).join('') + ''; + }); + + await renderMermaidDiagram(document, { mermaid: { run } }); + + const dimmed = (selector: string) => Array.from(document.querySelectorAll(selector), node => node.getAttribute('data-id')); + expect(dimmed('path.transition.lj-dimmed')).toEqual(['edge1', 'edge12']); + expect(dimmed('g.label.lj-dimmed')).toEqual(['edge1', 'edge12']); + }); +}); diff --git a/client/src/webview/diagram-focus.ts b/client/src/webview/diagram-focus.ts index 46c15a4..b2fb317 100644 --- a/client/src/webview/diagram-focus.ts +++ b/client/src/webview/diagram-focus.ts @@ -9,33 +9,43 @@ export const INVALID_STATE_ICON = '✕'; export type StateErrorFocus = { calledMethod: string | null; receiver: string | null; + // states the receiver may be in at the call + possibleStates: string[]; // possible states from which the call is accepted validStates: string[]; // possible states from which the call is rejected invalidStates: string[]; // states from which the call is accepted acceptedStates: string[]; + // whether nothing is known about the state of the receiver, so that every state is possible + unknownState: boolean; }; /** * 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 does not report the accepted states + * @returns the state error focus, or null when there is no error or the server reports no accepted states */ export function getStateErrorFocus(sm: LJStateMachine | undefined): StateErrorFocus | null { const context = sm?.errorContext; - if (!context?.expectedStates) return null; + const acceptedStates = sm?.states.filter(state => context?.expectedStates?.includes(state)) ?? []; + if (!sm || !context || acceptedStates.length === 0) return null; - const possible = context.possibleStates - ?? (context.definiteFoundState ? [context.definiteFoundState] : context.actualStates); + const possible = context.possibleStates?.length + ? context.possibleStates + : context.definiteFoundState ? [context.definiteFoundState] : context.actualStates; const possibleStates = sm.states.filter(state => possible.includes(state)); - const acceptedStates = sm.states.filter(state => context.expectedStates?.includes(state)); + // marking every state as valid or invalid would only say that the state is unknown + const unknownState = possibleStates.length === sm.states.length; + const markedStates = unknownState ? [] : possibleStates; return { calledMethod: context.calledMethod, receiver: context.receiver ?? null, - validStates: possibleStates.filter(state => acceptedStates.includes(state)), - invalidStates: possibleStates.filter(state => !acceptedStates.includes(state)), + possibleStates, + validStates: markedStates.filter(state => acceptedStates.includes(state)), + invalidStates: markedStates.filter(state => !acceptedStates.includes(state)), acceptedStates, + unknownState, }; } @@ -45,7 +55,7 @@ export function getStateErrorFocus(sm: LJStateMachine | undefined): StateErrorFo * @returns the related states */ export function getRelatedStates(focus: StateErrorFocus): string[] { - return [...new Set([...focus.validStates, ...focus.invalidStates, ...focus.acceptedStates])]; + return [...new Set([...focus.possibleStates, ...focus.acceptedStates])]; } /** diff --git a/client/src/webview/diagram.ts b/client/src/webview/diagram.ts index eb32e13..de6f288 100644 --- a/client/src/webview/diagram.ts +++ b/client/src/webview/diagram.ts @@ -8,6 +8,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; +// Mermaid comment listing the transitions to dim once rendered, which leaves the layout unchanged +const DIMMED_TRANSITIONS_COMMENT = '%% lj-dimmed-transitions:'; // state variables let zoomLevel = 1; @@ -35,8 +37,16 @@ export function createMermaidDiagram( const lines: string[] = []; const errorFocus = getStateErrorFocus(sm); - const relatedStates = focusOnError && errorFocus ? getRelatedStates(errorFocus) : null; - const isDimmed = (state: string) => !!relatedStates && !relatedStates.includes(state); + const relatedStates = focusOnError && errorFocus ? getRelatedStates(errorFocus) : []; + const isDimmed = (state: string) => relatedStates.length > 0 && !relatedStates.includes(state); + // Mermaid numbers the rendered transitions in the order they are declared + const dimmedTransitions: string[] = []; + let transitionCount = 0; + const addTransition = (line: string, dimmed: boolean) => { + if (dimmed) dimmedTransitions.push(`edge${transitionCount}`); + transitionCount++; + lines.push(line); + }; lines.push('stateDiagram-v2'); lines.push(` direction ${orientation}`); @@ -44,8 +54,7 @@ export function createMermaidDiagram( // initial transitions sm.initialTransitions.forEach(transition => { const label = getInitialTransitionLabel(transition.constructorSignature, transition.toCondition, showConditions); - const displayedLabel = isDimmed(transition.to) ? dimLabel(label) : label; - lines.push(` [*] --> ${transition.to}${displayedLabel ? ` : ${displayedLabel}` : ''}`); + addTransition(` [*] --> ${transition.to}${label ? ` : ${label}` : ''}`, isDimmed(transition.to)); }); // group transitions by from/to states and merge labels @@ -70,8 +79,7 @@ export function createMermaidDiagram( transitionMap.forEach(({ labels, hasCalledMethod }, key) => { const [from, to] = key.split('|'); const mergedLabel = labels.join('
'); - const dimmed = !hasCalledMethod && (isDimmed(from) || isDimmed(to)); - lines.push(` ${from} --> ${to} : ${dimmed ? dimLabel(mergedLabel) : mergedLabel}`); + addTransition(` ${from} --> ${to} : ${mergedLabel}`, !hasCalledMethod && (isDimmed(from) || isDimmed(to))); }); if (!errorFocus) { @@ -95,15 +103,11 @@ export function createMermaidDiagram( stateClasses.forEach(([states, className]) => { if (states.length > 0) lines.push(` class ${states.join(',')} ${className}`); }); + if (dimmedTransitions.length > 0) lines.push(`${DIMMED_TRANSITIONS_COMMENT} ${dimmedTransitions.join(' ')}`); return lines.join('\n'); } -// marks a transition label so that its transition is dimmed once rendered -function dimLabel(label: string): string { - return `${label}`; -} - function getTransitionLabel( label: string, fromCondition?: string | null, @@ -154,9 +158,10 @@ export async function renderMermaidDiagram(document: any, window: any) { if (mermaidElements.length === 0) return; try { + const dimmedTransitions = Array.from(mermaidElements, (element: any) => getDimmedTransitions(element.textContent)); await mermaid.run({ nodes: mermaidElements }); highlightErrorTransitions(document); - dimTransitions(document); + mermaidElements.forEach((element: any, index: number) => dimTransitions(element, dimmedTransitions[index])); applyTransform(document); registerPanListeners(document); const diagramContainer = document.querySelector('.diagram-container') as HTMLElement | null; @@ -180,15 +185,15 @@ function highlightErrorTransitions(document: any) { }); } -function dimTransitions(document: any) { - document.querySelectorAll('.lj-dimmed-transition').forEach((dimmedLabel: any) => { - const label = dimmedLabel.closest('g.label[data-id]'); - const edgeId = label?.getAttribute('data-id'); - const svg = label?.ownerSVGElement; - if (!edgeId || !svg) return; +function getDimmedTransitions(diagram: string | null): string[] { + const comment = diagram?.split('\n').find(line => line.startsWith(DIMMED_TRANSITIONS_COMMENT)); + return comment?.slice(DIMMED_TRANSITIONS_COMMENT.length).trim().split(/\s+/) ?? []; +} - label.classList.add('lj-dimmed'); - svg.querySelector(`path.transition[data-id="${edgeId}"]`)?.classList.add('lj-dimmed'); +function dimTransitions(element: any, edgeIds: string[]) { + edgeIds.forEach(edgeId => { + element.querySelectorAll(`path.transition[data-id="${edgeId}"], g.label[data-id="${edgeId}"]`) + .forEach((part: any) => part.classList.add('lj-dimmed')); }); } diff --git a/client/src/webview/script.spec.ts b/client/src/webview/script.spec.ts index 08c0ab6..24a944c 100644 --- a/client/src/webview/script.spec.ts +++ b/client/src/webview/script.spec.ts @@ -192,6 +192,42 @@ describe('webview script', () => { expect(root.querySelector('.mermaid')?.textContent).not.toContain('ljState'); }); + it('keeps the zoom when toggling the dimming and resets it for a new error', async () => { + showDiagnostics([stateError('State refinement error', errorMachine('Counter', 'close'))]); + click('.diagnostic-state-machine-btn'); + const transform = () => root.querySelector('#diagram-wrapper')?.style.transform; + await vi.waitFor(() => expect(transform()).toBe('matrix(1, 0, 0, 1, 0, 0)')); + + click('#zoom-in-btn'); + click('#diagram-focus-btn'); + await vi.waitFor(() => expect(runMermaid).toHaveBeenCalledTimes(2)); + await vi.waitFor(() => expect(transform()).toBe('matrix(1.5, 0, 0, 1.5, 0, 0)')); + + click('[data-tab="diagnostics"]'); + click('.diagnostic-state-machine-btn'); + await vi.waitFor(() => expect(transform()).toBe('matrix(1, 0, 0, 1, 0, 0)')); + }); + + it('copies the Mermaid source without the error overlay', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal('navigator', { clipboard: { writeText } }); + showDiagnostics([]); + receive({ type: 'fsm', sm: { ...errorMachine('Counter', 'close'), errorContext: null } }); + click('[data-tab="fsm"]'); + click('#copy-diagram-btn'); + const plain = writeText.mock.calls[0][0]; + + click('[data-tab="diagnostics"]'); + showDiagnostics([stateError('State refinement error', errorMachine('Counter', 'close'))]); + click('.diagnostic-state-machine-btn'); + await vi.waitFor(() => expect(root.querySelector('#copy-diagram-btn')?.disabled).toBe(false)); + click('#copy-diagram-btn'); + + expect(root.querySelector('.mermaid')?.textContent).toContain('ljStateDimmed'); + expect(writeText.mock.calls[1][0]).toBe(plain); + expect(plain).not.toMatch(/ljState|lj-dimmed|lj-state-error-method/); + }); + it('opens another state error focused after showing the full diagram of the previous one', () => { showDiagnostics([ stateError('First state error', errorMachine('First', 'close'), 4), diff --git a/client/src/webview/script.ts b/client/src/webview/script.ts index 5b3c0d7..3fe2fa8 100644 --- a/client/src/webview/script.ts +++ b/client/src/webview/script.ts @@ -206,7 +206,6 @@ export function getScript(vscode: VSCodeApi, document: Document, window: Window) if (target.closest?.('#diagram-focus-btn')) { e.stopPropagation(); focusDiagramOnError = !focusDiagramOnError; - resetZoom(document); updateView(); return; } @@ -416,7 +415,10 @@ export function getScript(vscode: VSCodeApi, document: Document, window: Window) const displayedStateMachine = diagnosticStateMachine ?? stateMachine; const focusOnError = focusDiagramOnError && !!diagnosticStateMachine; const diagram = createMermaidDiagram(displayedStateMachine, diagramOrientation, showDiagramConditions, focusOnError); - currentDiagram = diagram; + // copy the diagram without the error overlay + currentDiagram = displayedStateMachine?.errorContext + ? createMermaidDiagram({ ...displayedStateMachine, errorContext: null }, diagramOrientation, showDiagramConditions) + : diagram; renderStateMachineView(root, displayedStateMachine, diagram, diagramOrientation, showDiagramConditions, focusOnError); if (displayedStateMachine) renderMermaidDiagram(document, window); break; diff --git a/client/src/webview/views/fsm/fsm.spec.ts b/client/src/webview/views/fsm/fsm.spec.ts index d83d132..910f538 100644 --- a/client/src/webview/views/fsm/fsm.spec.ts +++ b/client/src/webview/views/fsm/fsm.spec.ts @@ -140,6 +140,29 @@ describe('state machine view', () => { expect(document.querySelector('.diagram-error-text')?.textContent).toBe('root.getFirstChild() is rejected when root is childrenAllowed'); }); + it.each([ + [ + 'the state of the receiver is unknown', + { possibleStates: ['unbound', 'bound', 'connected', 'closed'] }, + 'socket.receive() is rejected: the state of socket is unknown here \u00B7 allowed when unbound or bound or connected', + ], + [ + 'every possible state accepts the call', + { possibleStates: ['bound'] }, + 'socket.receive() is rejected here \u00B7 allowed when bound', + ], + ])('summarizes the error when %s', (_, errorContext, summary) => { + renderStateMachineView(document.body, machine({ + states: ['unbound', 'bound', 'connected', 'closed'], + errorContext: { + calledMethod: 'receive', actualStates: [], receiver: 'socket', + expectedStates: ['unbound', 'bound', 'connected'], ...errorContext, + }, + }), diagram, 'TB', false, true); + + expect(document.querySelector('.diagram-error-text')?.textContent).toBe(summary); + }); + it.each([ ['no error', null], ['an error context without the accepted states', { calledMethod: 'open', actualStates: ['Closed'] }], diff --git a/client/src/webview/views/fsm/fsm.ts b/client/src/webview/views/fsm/fsm.ts index 00ab827..c332d7e 100644 --- a/client/src/webview/views/fsm/fsm.ts +++ b/client/src/webview/views/fsm/fsm.ts @@ -57,14 +57,20 @@ function renderStateMachineViewHtml(sm: LJStateMachine | undefined, diagram: str function renderStateErrorSummary(focus: StateErrorFocus, focusOnError: boolean): string { const call = getRejectedCallText(focus); const receiver = focus.receiver ? `${escapeHtml(focus.receiver)}` : 'the receiver'; - const parts = [ - `${call ? `${escapeHtml(call)}` : 'The call'} is rejected` - + (focus.invalidStates.length > 0 ? ` when ${receiver} is ${renderStates(focus.invalidStates)}` : ''), - focus.validStates.length > 0 ? `allowed when ${renderStates(focus.validStates)}` : '', - ]; + const rejected = `${call ? `${escapeHtml(call)}` : 'The call'} is rejected`; + const allowedStates = focus.unknownState ? focus.acceptedStates : focus.validStates; + let summary: string; + if (focus.unknownState) { + summary = `${rejected}: the state of ${receiver} is unknown here`; + } else if (focus.invalidStates.length > 0) { + summary = `${rejected} when ${receiver} is ${renderStates(focus.invalidStates)}`; + } else { + summary = `${rejected} here`; + } + if (allowedStates.length > 0) summary += ` \u00B7 allowed when ${renderStates(allowedStates)}`; return /*html*/`
-

${parts.filter(Boolean).join(' \u00B7 ')}

+

${summary}

From 3d559a96bb61d64d6c461d360a805c96ebb5d87a Mon Sep 17 00:00:00 2001 From: Catarina Gamboa Date: Sat, 24 Oct 2026 02:53:18 +0100 Subject: [PATCH 7/7] Use liquidjava-fsm 0.1.5 for the receiver's possible states 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 53bef18..7cd2cb9 100644 --- a/server/pom.xml +++ b/server/pom.xml @@ -172,7 +172,7 @@ io.github.liquid-java liquidjava-fsm - 0.1.4 + 0.1.5 tools.aqua