Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions client/src/types/fsm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
};
228 changes: 228 additions & 0 deletions client/src/webview/diagram-focus.spec.ts
Original file line number Diff line number Diff line change
@@ -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<LJStateMachineErrorContext> | 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<LJStateMachineErrorContext> = {}): 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) => `<span class="lj-state-error-method">${label}</span>`;

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')}<br/>send`,
`bound --> bound : ${calledMethod('receive')}<br/>send`,
`connected --> connected : ${calledMethod('receive')}<br/>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<br/>${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')}<br/>send<br/>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 = '<div class="diagram-container"><div id="diagram-wrapper"><pre class="mermaid"></pre></div></div>';
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 = '<svg>' + ['edge0', 'edge1', 'edge12'].map(id =>
`<path class="transition" data-id="${id}"></path><g class="edgeLabel"><g class="label" data-id="${id}"></g></g>`).join('') + '</svg>';
});

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']);
});
});
69 changes: 69 additions & 0 deletions client/src/webview/diagram-focus.ts
Original file line number Diff line number Diff line change
@@ -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}()`;
}
Loading
Loading