diff --git a/client/src/types/fsm.ts b/client/src/types/fsm.ts index 0d252ec..2ed670a 100644 --- a/client/src/types/fsm.ts +++ b/client/src/types/fsm.ts @@ -11,4 +11,10 @@ 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; + possibleStates?: string[]; }; diff --git a/client/src/webview/diagram-focus.spec.ts b/client/src/webview/diagram-focus.spec.ts new file mode 100644 index 0000000..fd66d0e --- /dev/null +++ b/client/src/webview/diagram-focus.spec.ts @@ -0,0 +1,228 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { LJStateMachine, LJStateMachineErrorContext } from '../types/fsm'; +import { createMermaidDiagram, renderMermaidDiagram } from './diagram'; +import { getRelatedStates, getStateErrorFocus } from './diagram-focus'; + +// DefaultMutableTreeNode from the state error focus issue: +// 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'], + definiteFoundState: 'childrenAllowed', + possibleStates: ['childrenAllowed'], + ...errorContext, + }, + }; +} + +// 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']; + return { + 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: 'receive', + actualStates: ['bound', 'closed'], + receiver: 'socket', + expectedStates: open, + expectedCondition: 'unbound(socket) || bound(socket) || connected(socket)', + foundCondition: 'flag ? closed(socket) : bound(socket)', + definiteFoundState: null, + possibleStates: ['bound', 'closed'], + ...errorContext, + }, + }; +} + +function lines(diagram: string): string[] { + return diagram.split('\n').map(line => line.trim()); +} + +const dimmedTransitions = (diagram: string) => lines(diagram).filter(line => line.startsWith('%% lj-dimmed-transitions:')); +const calledMethod = (label: string) => `${label}`; + +describe('state error focus', () => { + it('splits the possible states into valid and invalid ones', () => { + 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']); + }); + + 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(focus?.validStates).toEqual(validStates); + expect(focus?.invalidStates).toEqual(invalidStates); + }); + + it('marks valid and invalid states and colours every edge of the called method', () => { + for (const focusOnError of [true, false]) { + 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('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(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' }); + + 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', () => { + const diagram = lines(createMermaidDiagram(treeNode(), 'TB', false, true)); + + 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 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 : ${calledMethod('getFirstChild')}`); + expect(diagram).toContain('class childrenAllowed ljStateErrorActual'); + expect(diagram).not.toMatch(/ljStateValid|ljStateInvalid|ljStateAccepted|ljStateDimmed|" as /); + }); + + it('leaves the general diagram unchanged without an error', () => { + expect(createMermaidDiagram(treeNode(null), 'TB', false, true)).toBe([ + 'stateDiagram-v2', + ' direction TB', + ' [*] --> childrenAllowed : DefaultMutableTreeNode(Object)', + ' childrenAllowed --> maybeHasChildren : add', + ' maybeHasChildren --> childrenAllowed : removeAllChildren', + ' maybeHasChildren --> maybeHasChildren : getFirstChild', + ].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 new file mode 100644 index 0000000..b2fb317 --- /dev/null +++ b/client/src/webview/diagram-focus.ts @@ -0,0 +1,69 @@ +import type { LJStateMachine } from "../types/fsm"; + +export const VALID_STATE_ICON = '✓'; +export const INVALID_STATE_ICON = '✕'; + +/** + * 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; + // 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 reports no accepted states + */ +export function getStateErrorFocus(sm: LJStateMachine | undefined): StateErrorFocus | null { + const context = sm?.errorContext; + const acceptedStates = sm?.states.filter(state => context?.expectedStates?.includes(state)) ?? []; + if (!sm || !context || acceptedStates.length === 0) return null; + + const possible = context.possibleStates?.length + ? context.possibleStates + : context.definiteFoundState ? [context.definiteFoundState] : context.actualStates; + const possibleStates = sm.states.filter(state => possible.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, + possibleStates, + validStates: markedStates.filter(state => acceptedStates.includes(state)), + invalidStates: markedStates.filter(state => !acceptedStates.includes(state)), + acceptedStates, + unknownState, + }; +} + +/** + * Gets the states related to a state error: the possible states of the receiver and the accepted states + * @param focus + * @returns the related states + */ +export function getRelatedStates(focus: StateErrorFocus): string[] { + return [...new Set([...focus.possibleStates, ...focus.acceptedStates])]; +} + +/** + * 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..de6f288 100644 --- a/client/src/webview/diagram.ts +++ b/client/src/webview/diagram.ts @@ -1,5 +1,6 @@ import type { LJStateMachine } from "../types/fsm"; import { copyToClipboard } from "./clipboard"; +import { INVALID_STATE_ICON, VALID_STATE_ICON, getRelatedStates, getStateErrorFocus } from "./diagram-focus"; // constants const MIN_ZOOM = 0.2; @@ -7,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; @@ -19,16 +22,31 @@ let startY = 0; /** * Converts a StateMachine object to a Mermaid state diagram string * @param sm + * @param orientation + * @param showConditions + * @param focusOnError whether to dim the states and transitions unrelated to 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 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}`); @@ -36,11 +54,11 @@ export function createMermaidDiagram( // initial transitions sm.initialTransitions.forEach(transition => { const label = getInitialTransitionLabel(transition.constructorSignature, transition.toCondition, showConditions); - lines.push(` [*] --> ${transition.to}${label ? ` : ${label}` : ''}`); + addTransition(` [*] --> ${transition.to}${label ? ` : ${label}` : ''}`, isDimmed(transition.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 label = getTransitionLabel( @@ -51,22 +69,42 @@ 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}`); + addTransition(` ${from} --> ${to} : ${mergedLabel}`, !hasCalledMethod && (isDimmed(from) || isDimmed(to))); }); - const highlightedStates = sm.states.filter(state => sm.errorContext?.actualStates.includes(state)); - if (highlightedStates.length > 0) { - lines.push(` class ${highlightedStates.join(',')} ljStateErrorActual`); + 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'); } + // 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}`); + }); + if (dimmedTransitions.length > 0) lines.push(`${DIMMED_TRANSITIONS_COMMENT} ${dimmedTransitions.join(' ')}`); + return lines.join('\n'); } @@ -120,8 +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); + mermaidElements.forEach((element: any, index: number) => dimTransitions(element, dimmedTransitions[index])); applyTransform(document); registerPanListeners(document); const diagramContainer = document.querySelector('.diagram-container') as HTMLElement | null; @@ -145,6 +185,18 @@ function highlightErrorTransitions(document: any) { }); } +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+/) ?? []; +} + +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')); + }); +} + function highlightTransitionMarker(svg: any, transition: any, edgeId: string) { const markerReference = transition.getAttribute('marker-end'); const markerId = markerReference?.match(/^url\(#(.+)\)$/)?.[1]; @@ -240,6 +292,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 25dfdca..24a944c 100644 --- a/client/src/webview/script.spec.ts +++ b/client/src/webview/script.spec.ts @@ -57,6 +57,30 @@ 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, + 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', + }, + }; +} + describe('webview script', () => { let dom: JSDOM; let root: HTMLElement; @@ -141,6 +165,103 @@ describe('webview script', () => { ]); }); + 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 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('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 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('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), + 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 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 () => { + 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-text 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/script.ts b/client/src/webview/script.ts index 05b6ed0..3fe2fa8 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,14 @@ export function getScript(vscode: VSCodeApi, document: Document, window: Window) return; } + // toggle the dimming of the diagram parts unrelated to the selected state error + if (target.closest?.('#diagram-focus-btn')) { + e.stopPropagation(); + focusDiagramOnError = !focusDiagramOnError; + updateView(); + return; + } + // zoom in if (target.closest?.('#zoom-in-btn')) { e.stopPropagation(); @@ -402,9 +413,13 @@ export function getScript(vscode: VSCodeApi, document: Document, window: Window) break; case 'fsm': { const displayedStateMachine = diagnosticStateMachine ?? stateMachine; - const diagram = createMermaidDiagram(displayedStateMachine, diagramOrientation, showDiagramConditions); - currentDiagram = diagram; - renderStateMachineView(root, displayedStateMachine, diagram, diagramOrientation, showDiagramConditions); + const focusOnError = focusDiagramOnError && !!diagnosticStateMachine; + const diagram = createMermaidDiagram(displayedStateMachine, diagramOrientation, showDiagramConditions, focusOnError); + // 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/styles.spec.ts b/client/src/webview/styles.spec.ts new file mode 100644 index 0000000..df25de0 --- /dev/null +++ b/client/src/webview/styles.spec.ts @@ -0,0 +1,61 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { getStyles } from './styles'; + +// state markup copied from a Mermaid 11 state diagram rendered in the webview +const state = (className: string) => /*html*/` + + + + `; + +function render(nodes: string) { + document.head.innerHTML = ``; + document.body.innerHTML = /*html*/` +
${nodes}
`; +} + +function style(selector: string): CSSStyleDeclaration { + return getComputedStyle(document.querySelector(selector)!); +} + +afterEach(() => { + document.head.innerHTML = ''; + document.body.innerHTML = ''; +}); + +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('.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('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-width')).toBe('1px'); + expect(outline.getPropertyValue('stroke-dasharray')).toBe('4 3'); + expect(outline.getPropertyValue('fill')).toBe('var(--vscode-editor-background)'); + }); + + it('dims unrelated states and transitions', () => { + render(state('ljStateDimmed') + ''); + + for (const selector of ['.ljStateDimmed', 'path.lj-dimmed', 'g.lj-dimmed']) { + expect(style(selector).opacity).toBe('0.25'); + } + }); + + it('keeps other states with the default outline', () => { + render(state('')); + + 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 e26a633..c83b10d 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-valid: var(--vscode-testing-iconPassed, var(--vscode-charts-green, #388a34)); } body.vscode-light { --lj-token-keyword: #0000FF; @@ -1016,5 +1017,43 @@ export function getStyles(): string { fill: var(--vscode-errorForeground) !important; 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; + stroke-width: 2px !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 .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: center; + justify-content: space-between; + gap: 0.5rem 1rem; + margin-bottom: 0.75rem; + font-size: 0.85rem; + cursor: default; + user-select: text; + } + .diagram-error-text { + min-width: 0; + margin: 0; + overflow-wrap: anywhere; + } + .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..910f538 100644 --- a/client/src/webview/views/fsm/fsm.spec.ts +++ b/client/src/webview/views/fsm/fsm.spec.ts @@ -109,6 +109,70 @@ describe('state machine view', () => { } }); + it('summarizes where the call is rejected and allowed in one line', () => { + const errorMachine = machine({ + states: ['unbound', 'bound', 'connected', 'closed'], + errorContext: { + calledMethod: 'receive', actualStates: ['bound', 'closed'], receiver: 'socket', + expectedStates: ['unbound', 'bound', 'connected'], possibleStates: ['bound', 'closed'], + }, + }); + 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(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('omits the allowed part of the summary when no possible state accepts the call', () => { + renderStateMachineView(document.body, machine({ + states: ['childrenAllowed', 'maybeHasChildren'], + errorContext: { + calledMethod: 'getFirstChild', actualStates: ['childrenAllowed'], receiver: 'root', + expectedStates: ['maybeHasChildren'], definiteFoundState: 'childrenAllowed', + }, + }), diagram, 'TB', false, true); + + 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'] }], + ])('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..c332d7e 100644 --- a/client/src/webview/views/fsm/fsm.ts +++ b/client/src/webview/views/fsm/fsm.ts @@ -1,15 +1,17 @@ import type { LJStateMachine } from "../../../types/fsm"; +import { getRejectedCallText, getStateErrorFocus, type StateErrorFocus } from "../../diagram-focus"; 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 +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) : ''} ${showConditions && hasConditionExpansions ? /*html*/`
Precondition @@ -50,3 +53,31 @@ 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 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*/` +
+

${summary}

+ +
+ `; +} + +function renderStates(states: string[]): string { + return states.map(state => `${escapeHtml(state)}`).join(' or '); +} diff --git a/server/pom.xml b/server/pom.xml index 514d511..7cd2cb9 100644 --- a/server/pom.xml +++ b/server/pom.xml @@ -172,7 +172,7 @@ io.github.liquid-java liquidjava-fsm - 0.1.3 + 0.1.5 tools.aqua