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..3998949 --- /dev/null +++ b/client/src/webview/diagram-focus.spec.ts @@ -0,0 +1,269 @@ +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}`; +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', () => { + 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', + ])); + 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']); + 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'); + // 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/); + }); + + 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' }, + { from: 'bound', to: 'connected', label: 'reconnect' }, + ); + + const diagram = createMermaidDiagram(sm, 'TB', false, true); + 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: edge0 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')}
${dimmedMethod('send')}
${dimmedMethod('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|" 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', () => { + 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')}`, + // 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/); + // 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', () => { + 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(['edge0', 'edge1', 'edge12']); + expect(dimmed('g.label.lj-dimmed')).toEqual(['edge0', '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..fd8c50b 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,30 +22,49 @@ 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}`); - // 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); - lines.push(` [*] --> ${transition.to}${label ? ` : ${label}` : ''}`); + 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 + 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, @@ -51,22 +73,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: [], dimmed: true }); + const group = transitionMap.get(key)!; + // 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, key) => { + // add transitions, dimming those whose methods are all dimmed + transitionMap.forEach(({ labels, dimmed }, key) => { const [from, to] = key.split('|'); - const mergedLabel = labels.join('
'); - lines.push(` ${from} --> ${to} : ${mergedLabel}`); + addTransition(` ${from} --> ${to} : ${labels.join('
')}`, dimmed); }); - 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 +162,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 +189,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 +296,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..d643e31 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,105 @@ 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.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"]'); + 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..d25def6 --- /dev/null +++ b/client/src/webview/styles.spec.ts @@ -0,0 +1,104 @@ +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(--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(--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(--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('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') + ''); + + for (const selector of ['.ljStateDimmed', 'path.lj-dimmed', 'g.lj-dimmed']) { + expect(style(selector).opacity).toBe('0.25'); + } + }); + + 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'); + + // 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', () => { + 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..9eabf6c 100644 --- a/client/src/webview/styles.ts +++ b/client/src/webview/styles.ts @@ -42,8 +42,13 @@ 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)); + --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; @@ -870,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; @@ -1016,5 +1025,86 @@ 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(--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 { + 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(--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 .edgeLabel .lj-dimmed-method { + opacity: 0.25; + } + .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; + 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; + } + .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 4bc540c..ad639c8 100644 --- a/client/src/webview/views/fsm/fsm.spec.ts +++ b/client/src/webview/views/fsm/fsm.spec.ts @@ -109,6 +109,101 @@ 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 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'); + + 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 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 in state unbound or bound or connected', + ], + [ + 'every possible state accepts the call', + { possibleStates: ['bound'] }, + 'socket.receive() is rejected here \u00B7 allowed in state 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([ + ['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'] }], + ])('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-error-legend')).toBeNull(); + 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 263090d..69904d2 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 { 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"; -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,14 +37,17 @@ 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) + renderStateErrorLegend(errorFocus) : ''} ${showConditions && hasConditionExpansions ? /*html*/`
Precondition Postcondition
` : ''} -
-
${diagram}
+
+
+
${diagram}
+
` @@ -50,3 +55,48 @@ 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 in state ${renderStates(focus.invalidStates)}`; + } else { + summary = `${rejected} here`; + } + if (allowedStates.length > 0) summary += ` \u00B7 allowed in state ${renderStates(allowedStates)}`; + return /*html*/` +
+

${summary}

+ +
+ `; +} + +// 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 '); +} 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