From bb889b9983facafe35e3c6d696158cae599448b8 Mon Sep 17 00:00:00 2001 From: Catarina Gamboa Date: Tue, 20 Oct 2026 23:49:19 +0100 Subject: [PATCH 01/11] 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 02/11] 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 03/11] 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 04/11] 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 05/11] 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 06/11] 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 07/11] 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 From 130a6c304864ef1ff5ebd3b7bf6b6d175045e197 Mon Sep 17 00:00:00 2001 From: Catarina Gamboa Date: Sat, 24 Oct 2026 12:02:36 +0100 Subject: [PATCH 08/11] Add a state legend and dim unhelpful methods in focus mode - Explain the state marks in a one line legend with the same outlines, fills, and icons as the diagram, shown only for the marks present. - In focus mode, dim each method name that neither is the failed method nor leaves a possible state for another state, including names inside merged labels, and dim a transition when all its methods are dimmed. Only styling hooks are added, so the layout matches the plain view. - Darken the dashed outline of the other accepted states in light themes. Co-Authored-By: Claude Opus 5.5 --- client/src/webview/diagram-focus.spec.ts | 73 ++++++++++++++++++------ client/src/webview/diagram.ts | 20 ++++--- client/src/webview/script.spec.ts | 4 +- client/src/webview/styles.spec.ts | 35 +++++++++++- client/src/webview/styles.ts | 50 ++++++++++++++-- client/src/webview/views/fsm/fsm.spec.ts | 19 ++++++ client/src/webview/views/fsm/fsm.ts | 21 ++++++- 7 files changed, 187 insertions(+), 35 deletions(-) diff --git a/client/src/webview/diagram-focus.spec.ts b/client/src/webview/diagram-focus.spec.ts index fd66d0e..2cea632 100644 --- a/client/src/webview/diagram-focus.spec.ts +++ b/client/src/webview/diagram-focus.spec.ts @@ -65,6 +65,10 @@ function lines(diagram: string): string[] { const dimmedTransitions = (diagram: string) => lines(diagram).filter(line => line.startsWith('%% lj-dimmed-transitions:')); const calledMethod = (label: string) => `${label}`; +const dimmedMethod = (label: string) => `${label}`; +// removes the hooks that only style the focused diagram +const withoutDimming = (diagram: string) => lines(diagram.replace(/(.*?)<\/span>/g, '$1')) + .filter(line => !line.startsWith('%%') && !line.endsWith('ljStateDimmed')); describe('state error focus', () => { it('splits the possible states into valid and invalid ones', () => { @@ -99,10 +103,11 @@ describe('state error focus', () => { '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`, ])); + const send = focusOnError ? dimmedMethod('send') : 'send'; + for (const state of ['unbound', 'bound', 'connected']) { + expect(diagram).toContain(`${state} --> ${state} : ${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']); @@ -115,29 +120,58 @@ describe('state error focus', () => { const full = createMermaidDiagram(datagramSocket(), 'TB', false, false); 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([]); + // the initial transition into broken, then bind, disconnect, close from unbound and connected, repair, and discard + expect(dimmedTransitions(focused)).toEqual(['%% lj-dimmed-transitions: edge1 edge2 edge4 edge8 edge10 edge11 edge12']); + // focusing only adds styling hooks, so the layout stays the same + expect(withoutDimming(focused)).toEqual(lines(full)); + expect(full).not.toMatch(/ljStateDimmed|lj-dimmed/); }); - it('keeps edges of the called method undimmed even between unrelated states', () => { + it('only keeps the failed method and the ways out of the possible states bright', () => { + const diagram = lines(createMermaidDiagram(datagramSocket(), 'TB', false, true)); + + expect(diagram).toEqual(expect.arrayContaining([ + // leaving a possible state for another state + 'bound --> connected : connect', + 'bound --> closed : close', + // self loops and transitions from other states + `bound --> bound : ${calledMethod('receive')}
${dimmedMethod('send')}`, + `unbound --> bound : ${dimmedMethod('bind')}`, + `connected --> bound : ${dimmedMethod('disconnect')}`, + `broken --> closed : ${dimmedMethod('discard')}`, + ])); + }); + + it('dims each method of a merged label, and the transition only when all of them are dimmed', () => { const sm = datagramSocket(); - sm.transitions.push({ from: 'broken', to: 'broken', label: 'receive' }); + sm.transitions.push( + { from: 'broken', to: 'broken', label: 'receive' }, + { from: 'bound', to: 'connected', label: 'reconnect' }, + ); 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']); + expect(lines(diagram)).toEqual(expect.arrayContaining([ + `broken --> broken : ${dimmedMethod('repair')}
${calledMethod('receive')}`, + 'bound --> connected : connect
reconnect', + ])); + // the self loop of broken keeps its path for the failed method + expect(dimmedTransitions(diagram)).toEqual(['%% lj-dimmed-transitions: edge1 edge2 edge4 edge8 edge10 edge12']); + }); + + it('dims the conditions of a dimmed method with it', () => { + const sm = datagramSocket(); + sm.transitions[0] = { ...sm.transitions[0], fromCondition: 'unbound(this)' }; + + expect(lines(createMermaidDiagram(sm, 'TB', true, true))).toContain( + `unbound --> bound : ${dimmedMethod('unbound(this)
bind')}`); }); 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`); + expect(lines(createMermaidDiagram(sm, 'TB', false, true))) + .toContain(`bound --> bound : ${calledMethod('receive')}
${dimmedMethod('send')}
${dimmedMethod('receiveAll')}`); }); it.each([ @@ -164,7 +198,9 @@ describe('state error focus', () => { 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 /); + expect(diagram).not.toMatch(/ljStateValid|ljStateInvalid|ljStateDimmed|" as /); + // every state is possible, so only the self loops other than the failed method are dimmed + expect(lines(diagram)).toEqual(expect.arrayContaining(['unbound --> bound : bind', `broken --> broken : ${dimmedMethod('repair')}`])); }); it('marks the tree node example without an allowed state', () => { @@ -175,8 +211,11 @@ describe('state error focus', () => { 'class childrenAllowed ljStateInvalid', 'class maybeHasChildren ljStateAccepted', `maybeHasChildren --> maybeHasChildren : ${calledMethod('getFirstChild')}`, + // add leaves childrenAllowed, removeAllChildren does not start from a possible state + 'childrenAllowed --> maybeHasChildren : add', + `maybeHasChildren --> childrenAllowed : ${dimmedMethod('removeAllChildren')}`, ])); - expect(diagram.join('\n')).not.toMatch(/maybeHasChildren" as|ljStateValid|ljStateDimmed|lj-dimmed-transition/); + expect(diagram.join('\n')).not.toMatch(/maybeHasChildren" as|ljStateValid|ljStateDimmed/); }); it('keeps the previous highlighting for an error context without the accepted states', () => { diff --git a/client/src/webview/diagram.ts b/client/src/webview/diagram.ts index de6f288..42312c7 100644 --- a/client/src/webview/diagram.ts +++ b/client/src/webview/diagram.ts @@ -57,10 +57,14 @@ export function createMermaidDiagram( addTransition(` [*] --> ${transition.to}${label ? ` : ${label}` : ''}`, isDimmed(transition.to)); }); + // in focus mode, only the failed method and the ways out of the possible states stay bright + const isUsefulTransition = (from: string, to: string) => !!errorFocus?.possibleStates.includes(from) && from !== to; + // group transitions by from/to states and merge labels - const transitionMap = new Map(); + const transitionMap = new Map(); sm.transitions.forEach(transition => { const isCalledMethod = transition.label === sm.errorContext?.calledMethod; + const dimmed = relatedStates.length > 0 && !isCalledMethod && !isUsefulTransition(transition.from, transition.to); const label = getTransitionLabel( transition.label, transition.fromCondition, @@ -69,17 +73,17 @@ export function createMermaidDiagram( isCalledMethod, ); const key = `${transition.from}|${transition.to}`; - if (!transitionMap.has(key)) transitionMap.set(key, { labels: [], hasCalledMethod: false }); + if (!transitionMap.has(key)) transitionMap.set(key, { labels: [], dimmed: true }); const group = transitionMap.get(key)!; - group.labels.push(label); - group.hasCalledMethod ||= isCalledMethod; + // wrapping the label keeps its text, so the layout stays the same + group.labels.push(dimmed ? `${label}` : label); + group.dimmed &&= dimmed; }); - // add transitions - transitionMap.forEach(({ labels, hasCalledMethod }, key) => { + // add transitions, dimming those whose methods are all dimmed + transitionMap.forEach(({ labels, dimmed }, key) => { const [from, to] = key.split('|'); - const mergedLabel = labels.join('
'); - addTransition(` ${from} --> ${to} : ${mergedLabel}`, !hasCalledMethod && (isDimmed(from) || isDimmed(to))); + addTransition(` ${from} --> ${to} : ${labels.join('
')}`, dimmed); }); if (!errorFocus) { diff --git a/client/src/webview/script.spec.ts b/client/src/webview/script.spec.ts index 24a944c..d643e31 100644 --- a/client/src/webview/script.spec.ts +++ b/client/src/webview/script.spec.ts @@ -178,11 +178,13 @@ describe('webview script', () => { click('#diagram-focus-btn'); expect(root.querySelector('#diagram-focus-btn')?.textContent?.trim()).toBe('Focus on error'); expect(root.querySelector('.mermaid')?.textContent).toContain('Broken --> Broken : reopen()'); - expect(root.querySelector('.mermaid')?.textContent).not.toContain('ljStateDimmed'); + expect(root.querySelector('.mermaid')?.textContent).not.toMatch(/ljStateDimmed|lj-dimmed/); + expect(root.querySelector('.mermaid .lj-dimmed-method')).toBeNull(); click('#diagram-focus-btn'); expect(root.querySelector('#diagram-focus-btn')?.textContent?.trim()).toBe('Show all'); expect(root.querySelector('.mermaid')?.textContent).toContain('class Broken ljStateDimmed'); + expect(Array.from(root.querySelectorAll('.mermaid .lj-dimmed-method'), node => node.textContent)).toEqual(['close()', 'reopen()']); // the general diagram of the file has no error to focus on click('[data-tab="diagnostics"]'); diff --git a/client/src/webview/styles.spec.ts b/client/src/webview/styles.spec.ts index df25de0..ee42252 100644 --- a/client/src/webview/styles.spec.ts +++ b/client/src/webview/styles.spec.ts @@ -27,23 +27,42 @@ describe('state error styles', () => { 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('.ljStateInvalid .label-container').getPropertyValue('stroke')).toBe('var(--lj-state-invalid)'); 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))'); + .toBe('color-mix(in srgb, var(--lj-state-invalid) 10%, var(--vscode-editor-background))'); }); it('hints at the other accepted states with a thin dashed warning outline', () => { render(state('ljStateAccepted')); const outline = style('.ljStateAccepted .label-container'); - expect(outline.getPropertyValue('stroke')).toBe('var(--vscode-editorWarning-foreground, #cca700)'); + expect(outline.getPropertyValue('stroke')).toBe('var(--lj-state-accepted)'); 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('draws the legend swatches like the marked states', () => { + render(''); + document.body.insertAdjacentHTML('beforeend', ['invalid', 'valid', 'accepted'] + .map(kind => ``).join('')); + + // jsdom does not compute colours given as variables, so read the declared colour + const declaredColour = (selector: string) => Array.from(document.styleSheets[0].cssRules) + .map(rule => rule as CSSStyleRule) + .find(rule => rule.selectorText === selector)?.style.getPropertyValue('border-color'); + const border = (kind: string) => { + const swatch = style(`.diagram-error-legend-${kind}`); + return [swatch.getPropertyValue('border-top-width'), swatch.getPropertyValue('border-top-style'), + declaredColour(`.diagram-error-legend-${kind}`)]; + }; + expect(border('invalid')).toEqual(['2px', 'solid', 'var(--lj-state-invalid)']); + expect(border('valid')).toEqual(['2px', 'solid', 'var(--lj-state-valid)']); + expect(border('accepted')).toEqual(['1px', 'dashed', 'var(--lj-state-accepted)']); + }); + it('dims unrelated states and transitions', () => { render(state('ljStateDimmed') + ''); @@ -52,6 +71,16 @@ describe('state error styles', () => { } }); + it('dims single methods of a label without dimming a dimmed transition twice', () => { + const label = (className: string, method: string) => `` + + `

${method}

`; + render(label('label', 'send') + label('label lj-dimmed', 'bind')); + + const [method, inDimmedTransition] = document.querySelectorAll('.lj-dimmed-method'); + expect(getComputedStyle(method).opacity).toBe('0.25'); + expect(getComputedStyle(inDimmedTransition).opacity).toBe('1'); + }); + it('keeps other states with the default outline', () => { render(state('')); diff --git a/client/src/webview/styles.ts b/client/src/webview/styles.ts index c83b10d..4107561 100644 --- a/client/src/webview/styles.ts +++ b/client/src/webview/styles.ts @@ -43,8 +43,12 @@ export function getStyles(): string { --lj-state-cond-pre: var(--lj-token-identifier); --lj-state-cond-post: var(--lj-token-type); --lj-state-valid: var(--vscode-testing-iconPassed, var(--vscode-charts-green, #388a34)); + --lj-state-invalid: var(--vscode-errorForeground); + --lj-state-accepted: var(--vscode-editorWarning-foreground, #cca700); } body.vscode-light { + /* the light warning colour is too faint for a thin dashed outline on white */ + --lj-state-accepted: color-mix(in srgb, var(--vscode-editorWarning-foreground, #bf8803) 75%, black); --lj-token-keyword: #0000FF; --lj-token-function: #795E26; --lj-token-operator: #000000; @@ -1018,8 +1022,8 @@ export function getStyles(): string { stroke: var(--vscode-errorForeground) !important; } .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; + fill: color-mix(in srgb, var(--lj-state-invalid) 10%, var(--vscode-editor-background)) !important; + stroke: var(--lj-state-invalid) !important; stroke-width: 2px !important; } .diagram-container .mermaid svg .ljStateValid .label-container { @@ -1028,14 +1032,18 @@ export function getStyles(): string { stroke-width: 2px !important; } .diagram-container .mermaid svg .ljStateAccepted .label-container { - stroke: var(--vscode-editorWarning-foreground, #cca700) !important; + stroke: var(--lj-state-accepted) !important; stroke-width: 1px !important; stroke-dasharray: 4 3; } .diagram-container .mermaid svg .ljStateDimmed, - .diagram-container .mermaid svg .lj-dimmed { + .diagram-container .mermaid svg .lj-dimmed, + .diagram-container .mermaid .edgeLabel .lj-dimmed-method { opacity: 0.25; } + .diagram-container .mermaid svg .lj-dimmed .lj-dimmed-method { + opacity: 1; + } .diagram-error-summary { display: flex; flex-wrap: wrap; @@ -1055,5 +1063,39 @@ export function getStyles(): string { .diagram-focus-btn { flex-shrink: 0; } + .diagram-error-legend { + display: flex; + flex-wrap: wrap; + gap: 0.25rem 1rem; + margin-bottom: 0.75rem; + font-size: 0.8rem; + font-weight: 500; + } + .diagram-error-legend-item { + display: inline-flex; + align-items: center; + gap: 0.35rem; + white-space: nowrap; + } + .diagram-error-legend-swatch { + flex-shrink: 0; + width: 0.9rem; + height: 0.6rem; + border-radius: 2px; + } + .diagram-error-legend-invalid { + background: color-mix(in srgb, var(--lj-state-invalid) 10%, var(--vscode-editor-background)); + border: 2px solid; + border-color: var(--lj-state-invalid); + } + .diagram-error-legend-valid { + background: color-mix(in srgb, var(--lj-state-valid) 10%, var(--vscode-editor-background)); + border: 2px solid; + border-color: var(--lj-state-valid); + } + .diagram-error-legend-accepted { + border: 1px dashed; + border-color: var(--lj-state-accepted); + } `; } diff --git a/client/src/webview/views/fsm/fsm.spec.ts b/client/src/webview/views/fsm/fsm.spec.ts index 910f538..089d7b3 100644 --- a/client/src/webview/views/fsm/fsm.spec.ts +++ b/client/src/webview/views/fsm/fsm.spec.ts @@ -163,6 +163,24 @@ describe('state machine view', () => { expect(document.querySelector('.diagram-error-text')?.textContent).toBe(summary); }); + it.each([ + ['invalid, valid, and other accepted states', ['bound', 'closed'], ['\u2715 Call rejected', '\u2713 Call allowed', 'Allowed, but not reachable here']], + ['only an invalid and other accepted states', ['closed'], ['\u2715 Call rejected', 'Allowed, but not reachable here']], + ['an unknown receiver state', ['unbound', 'bound', 'connected', 'closed'], ['Allowed, but not reachable here']], + ])('explains the state marks in a legend with %s', (_, possibleStates, items) => { + renderStateMachineView(document.body, machine({ + states: ['unbound', 'bound', 'connected', 'closed'], + errorContext: { + calledMethod: 'receive', actualStates: [], receiver: 'socket', + expectedStates: ['unbound', 'bound', 'connected'], possibleStates, + }, + }), diagram, 'TB', false, true); + + const legend = document.querySelectorAll('.diagram-error-legend-item'); + expect(Array.from(legend, item => item.textContent)).toEqual(items); + expect(Array.from(legend, item => item.querySelector('.diagram-error-legend-swatch')?.getAttribute('aria-hidden'))).toEqual(items.map(() => 'true')); + }); + it.each([ ['no error', null], ['an error context without the accepted states', { calledMethod: 'open', actualStates: ['Closed'] }], @@ -170,6 +188,7 @@ describe('state machine view', () => { renderStateMachineView(document.body, machine({ errorContext }), diagram, 'TB', false, true); expect(document.querySelector('.diagram-error-summary')).toBeNull(); + expect(document.querySelector('.diagram-error-legend')).toBeNull(); expect(document.querySelector('#diagram-focus-btn')).toBeNull(); }); diff --git a/client/src/webview/views/fsm/fsm.ts b/client/src/webview/views/fsm/fsm.ts index c332d7e..56761c7 100644 --- a/client/src/webview/views/fsm/fsm.ts +++ b/client/src/webview/views/fsm/fsm.ts @@ -1,5 +1,5 @@ import type { LJStateMachine } from "../../../types/fsm"; -import { getRejectedCallText, getStateErrorFocus, type StateErrorFocus } from "../../diagram-focus"; +import { INVALID_STATE_ICON, VALID_STATE_ICON, getRejectedCallText, getStateErrorFocus, type StateErrorFocus } from "../../diagram-focus"; import { renderCodiconButton } from "../../icons"; import { escapeHtml } from "../../utils"; import { renderMainHeader } from "../sections"; @@ -37,7 +37,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) : ''} + ${errorFocus ? renderStateErrorSummary(errorFocus, focusOnError) + renderStateErrorLegend(errorFocus) : ''} ${showConditions && hasConditionExpansions ? /*html*/`
Precondition @@ -78,6 +78,23 @@ function renderStateErrorSummary(focus: StateErrorFocus, focusOnError: boolean): `; } +// explains the state marks with the same outlines, fills, and icons as the diagram +function renderStateErrorLegend(focus: StateErrorFocus): string { + const items = [ + focus.invalidStates.length > 0 ? renderLegendItem('invalid', `${INVALID_STATE_ICON} Call rejected`) : '', + focus.validStates.length > 0 ? renderLegendItem('valid', `${VALID_STATE_ICON} Call allowed`) : '', + focus.acceptedStates.some(state => !focus.validStates.includes(state)) + ? renderLegendItem('accepted', 'Allowed, but not reachable here') + : '', + ].filter(Boolean); + if (items.length === 0) return ''; + return `
${items.join('')}
`; +} + +function renderLegendItem(kind: string, label: string): string { + return `${label}`; +} + function renderStates(states: string[]): string { return states.map(state => `${escapeHtml(state)}`).join(' or '); } From b15a1fcc4d92fb5782b5f38a67724ea70a0f1c5a Mon Sep 17 00:00:00 2001 From: Catarina Gamboa Date: Sat, 24 Oct 2026 12:40:14 +0100 Subject: [PATCH 09/11] Dim constructors in focus mode and clip the panned diagram - In focus mode, dim the initial transitions too, since the receiver already exists at the failing call. - Clip the diagram to its own viewport so that panning it upwards no longer draws it over the error summary and legend. Co-Authored-By: Claude Opus 5.5 --- client/src/webview/diagram-focus.spec.ts | 12 +++++++----- client/src/webview/diagram.ts | 4 ++-- client/src/webview/styles.spec.ts | 7 +++++++ client/src/webview/styles.ts | 4 ++++ client/src/webview/views/fsm/fsm.spec.ts | 12 ++++++++++++ client/src/webview/views/fsm/fsm.ts | 6 ++++-- 6 files changed, 36 insertions(+), 9 deletions(-) diff --git a/client/src/webview/diagram-focus.spec.ts b/client/src/webview/diagram-focus.spec.ts index 2cea632..3998949 100644 --- a/client/src/webview/diagram-focus.spec.ts +++ b/client/src/webview/diagram-focus.spec.ts @@ -120,8 +120,8 @@ describe('state error focus', () => { const full = createMermaidDiagram(datagramSocket(), 'TB', false, false); expect(lines(focused)).toContain('class broken ljStateDimmed'); - // the initial transition into broken, then bind, disconnect, close from unbound and connected, repair, and discard - expect(dimmedTransitions(focused)).toEqual(['%% lj-dimmed-transitions: edge1 edge2 edge4 edge8 edge10 edge11 edge12']); + // both initial transitions, then bind, disconnect, close from unbound and connected, repair, and discard + expect(dimmedTransitions(focused)).toEqual(['%% lj-dimmed-transitions: edge0 edge1 edge2 edge4 edge8 edge10 edge11 edge12']); // focusing only adds styling hooks, so the layout stays the same expect(withoutDimming(focused)).toEqual(lines(full)); expect(full).not.toMatch(/ljStateDimmed|lj-dimmed/); @@ -155,7 +155,7 @@ describe('state error focus', () => { 'bound --> connected : connect
reconnect', ])); // the self loop of broken keeps its path for the failed method - expect(dimmedTransitions(diagram)).toEqual(['%% lj-dimmed-transitions: edge1 edge2 edge4 edge8 edge10 edge12']); + expect(dimmedTransitions(diagram)).toEqual(['%% lj-dimmed-transitions: edge0 edge1 edge2 edge4 edge8 edge10 edge12']); }); it('dims the conditions of a dimmed method with it', () => { @@ -216,6 +216,8 @@ describe('state error focus', () => { `maybeHasChildren --> childrenAllowed : ${dimmedMethod('removeAllChildren')}`, ])); expect(diagram.join('\n')).not.toMatch(/maybeHasChildren" as|ljStateValid|ljStateDimmed/); + // the constructor is dimmed although it leads to a possible state + expect(dimmedTransitions(diagram.join('\n'))).toEqual(['%% lj-dimmed-transitions: edge0 edge2']); }); it('keeps the previous highlighting for an error context without the accepted states', () => { @@ -261,7 +263,7 @@ describe('rendered state error focus', () => { 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']); + expect(dimmed('path.transition.lj-dimmed')).toEqual(['edge0', 'edge1', 'edge12']); + expect(dimmed('g.label.lj-dimmed')).toEqual(['edge0', 'edge1', 'edge12']); }); }); diff --git a/client/src/webview/diagram.ts b/client/src/webview/diagram.ts index 42312c7..fd8c50b 100644 --- a/client/src/webview/diagram.ts +++ b/client/src/webview/diagram.ts @@ -51,10 +51,10 @@ export function createMermaidDiagram( lines.push('stateDiagram-v2'); lines.push(` direction ${orientation}`); - // initial transitions + // initial transitions, dimmed in focus mode since the receiver already exists at the call sm.initialTransitions.forEach(transition => { const label = getInitialTransitionLabel(transition.constructorSignature, transition.toCondition, showConditions); - addTransition(` [*] --> ${transition.to}${label ? ` : ${label}` : ''}`, isDimmed(transition.to)); + addTransition(` [*] --> ${transition.to}${label ? ` : ${label}` : ''}`, relatedStates.length > 0); }); // in focus mode, only the failed method and the ways out of the possible states stay bright diff --git a/client/src/webview/styles.spec.ts b/client/src/webview/styles.spec.ts index ee42252..749b6ce 100644 --- a/client/src/webview/styles.spec.ts +++ b/client/src/webview/styles.spec.ts @@ -63,6 +63,13 @@ describe('state error styles', () => { expect(border('accepted')).toEqual(['1px', 'dashed', 'var(--lj-state-accepted)']); }); + it('clips the panned diagram to its viewport', () => { + render(''); + document.body.insertAdjacentHTML('beforeend', '
'); + + expect(style('.diagram-viewport').overflow).toBe('hidden'); + }); + it('dims unrelated states and transitions', () => { render(state('ljStateDimmed') + ''); diff --git a/client/src/webview/styles.ts b/client/src/webview/styles.ts index 4107561..c9be604 100644 --- a/client/src/webview/styles.ts +++ b/client/src/webview/styles.ts @@ -875,6 +875,10 @@ export function getStyles(): string { .diagram-container:active { cursor: grabbing; } + /* clips the panned diagram so that it stays below the header, summary, and legend */ + .diagram-viewport { + overflow: hidden; + } .diagram-wrapper { transition: transform 0.1s ease-out; transform-origin: 0 0; diff --git a/client/src/webview/views/fsm/fsm.spec.ts b/client/src/webview/views/fsm/fsm.spec.ts index 089d7b3..c0e6d2f 100644 --- a/client/src/webview/views/fsm/fsm.spec.ts +++ b/client/src/webview/views/fsm/fsm.spec.ts @@ -192,6 +192,18 @@ describe('state machine view', () => { expect(document.querySelector('#diagram-focus-btn')).toBeNull(); }); + it('clips the panned diagram below the error summary and legend', () => { + renderStateMachineView(document.body, machine({ + errorContext: { calledMethod: 'read', actualStates: ['Closed'], expectedStates: ['Open'], possibleStates: ['Closed'] }, + }), diagram, 'TB', false, true); + + const viewport = document.querySelector('.diagram-viewport')!; + expect(viewport.querySelector('#diagram-wrapper .mermaid')).not.toBeNull(); + for (const selector of ['.diagram-header', '.diagram-error-summary', '.diagram-error-legend']) { + expect(document.querySelector(selector)?.compareDocumentPosition(viewport)).toBe(Node.DOCUMENT_POSITION_FOLLOWING); + } + }); + 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 56761c7..6f02cc3 100644 --- a/client/src/webview/views/fsm/fsm.ts +++ b/client/src/webview/views/fsm/fsm.ts @@ -44,8 +44,10 @@ function renderStateMachineViewHtml(sm: LJStateMachine | undefined, diagram: str Postcondition
` : ''} -
-
${diagram}
+
+
+
${diagram}
+
` From 08b0dc80b7d6125cc7a7edbb0ce816a50fb0c3bc Mon Sep 17 00:00:00 2001 From: Catarina Gamboa Date: Sat, 24 Oct 2026 12:45:28 +0100 Subject: [PATCH 10/11] Clear the label background of dimmed transitions The opaque background of a fully dimmed label stood out from its dimmed transition, so make it transparent. Labels with a bright method keep it. Co-Authored-By: Claude Opus 5.5 --- client/src/webview/styles.spec.ts | 7 +++++++ client/src/webview/styles.ts | 5 +++++ 2 files changed, 12 insertions(+) diff --git a/client/src/webview/styles.spec.ts b/client/src/webview/styles.spec.ts index 749b6ce..d25def6 100644 --- a/client/src/webview/styles.spec.ts +++ b/client/src/webview/styles.spec.ts @@ -86,6 +86,13 @@ describe('state error styles', () => { const [method, inDimmedTransition] = document.querySelectorAll('.lj-dimmed-method'); expect(getComputedStyle(method).opacity).toBe('0.25'); expect(getComputedStyle(inDimmedTransition).opacity).toBe('1'); + + // only the background of a fully dimmed label is cleared + const [bright, dimmed] = document.querySelectorAll('g.label'); + expect(getComputedStyle(bright.querySelector('div')!).getPropertyValue('background')).toBe('var(--vscode-editor-background)'); + for (const part of dimmed.querySelectorAll('div, span, p')) { + expect(getComputedStyle(part).getPropertyValue('background')).toBe('transparent'); + } }); it('keeps other states with the default outline', () => { diff --git a/client/src/webview/styles.ts b/client/src/webview/styles.ts index c9be604..9eabf6c 100644 --- a/client/src/webview/styles.ts +++ b/client/src/webview/styles.ts @@ -1048,6 +1048,11 @@ export function getStyles(): string { .diagram-container .mermaid svg .lj-dimmed .lj-dimmed-method { opacity: 1; } + /* the opaque label background would stand out from the dimmed transition */ + .diagram-container .mermaid .edgeLabel .lj-dimmed, + .diagram-container .mermaid .edgeLabel .lj-dimmed * { + background: transparent !important; + } .diagram-error-summary { display: flex; flex-wrap: wrap; From 609c6dbdeca966564d392a1114dc67c7e596c88d Mon Sep 17 00:00:00 2001 From: Catarina Gamboa Date: Sat, 24 Oct 2026 22:21:13 +0100 Subject: [PATCH 11/11] Name states explicitly in the state error summary Co-Authored-By: Claude Opus 5.5 --- client/src/webview/views/fsm/fsm.spec.ts | 8 ++++---- client/src/webview/views/fsm/fsm.ts | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/client/src/webview/views/fsm/fsm.spec.ts b/client/src/webview/views/fsm/fsm.spec.ts index c0e6d2f..ad639c8 100644 --- a/client/src/webview/views/fsm/fsm.spec.ts +++ b/client/src/webview/views/fsm/fsm.spec.ts @@ -120,7 +120,7 @@ describe('state machine view', () => { renderStateMachineView(document.body, errorMachine, diagram, 'TB', false, true); const summary = document.querySelector('.diagram-error-text')!; - expect(summary.textContent).toBe('socket.receive() is rejected when socket is closed \u00B7 allowed when bound'); + expect(summary.textContent).toBe('socket.receive() is rejected when socket is in state closed \u00B7 allowed in state 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'); @@ -137,19 +137,19 @@ describe('state machine view', () => { }, }), diagram, 'TB', false, true); - expect(document.querySelector('.diagram-error-text')?.textContent).toBe('root.getFirstChild() is rejected when root is childrenAllowed'); + expect(document.querySelector('.diagram-error-text')?.textContent).toBe('root.getFirstChild() is rejected when root is in state 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', + 'socket.receive() is rejected: the state of socket is unknown here \u00B7 allowed in state unbound or bound or connected', ], [ 'every possible state accepts the call', { possibleStates: ['bound'] }, - 'socket.receive() is rejected here \u00B7 allowed when bound', + 'socket.receive() is rejected here \u00B7 allowed in state bound', ], ])('summarizes the error when %s', (_, errorContext, summary) => { renderStateMachineView(document.body, machine({ diff --git a/client/src/webview/views/fsm/fsm.ts b/client/src/webview/views/fsm/fsm.ts index 6f02cc3..69904d2 100644 --- a/client/src/webview/views/fsm/fsm.ts +++ b/client/src/webview/views/fsm/fsm.ts @@ -65,11 +65,11 @@ function renderStateErrorSummary(focus: StateErrorFocus, focusOnError: boolean): 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)}`; + summary = `${rejected} when ${receiver} is in state ${renderStates(focus.invalidStates)}`; } else { summary = `${rejected} here`; } - if (allowedStates.length > 0) summary += ` \u00B7 allowed when ${renderStates(allowedStates)}`; + if (allowedStates.length > 0) summary += ` \u00B7 allowed in state ${renderStates(allowedStates)}`; return /*html*/`

${summary}