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
85 changes: 81 additions & 4 deletions client/src/webview/diagram-focus.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ describe('state error focus', () => {

expect(lines(createMermaidDiagram(sm, 'TB', false, false))).toContain('[*] --> maybeHasChildren : isLeaf');
expect(lines(createMermaidDiagram(sm, 'TB', true, false))).toContain(
'[*] --> maybeHasChildren : isLeaf<br/><span class="state-cond state-cond-post">!return</span>');
'[*] --> maybeHasChildren : isLeaf <span class="state-cond state-cond-post">[!return]</span>');
// the constructor and the entry transition come first
expect(dimmedTransitions(createMermaidDiagram(sm, 'TB', false, true))[0]).toMatch(/^%% lj-dimmed-transitions: edge0 edge1\b/);
});
Expand All @@ -130,8 +130,8 @@ describe('state error focus', () => {
] };

expect(lines(createMermaidDiagram(sm, 'TB', true, true))).toContain(`[*] --> maybeHasChildren : ${collapsedMethods([
'isLeaf<br/><span class="state-cond state-cond-post">!return</span>',
'getChildCount<br/><span class="state-cond state-cond-post">return &gt; 0</span>',
'isLeaf <span class="state-cond state-cond-post">[!return]</span>',
'getChildCount <span class="state-cond state-cond-post">[return &gt; 0]</span>',
], '2 methods')}`);
});

Expand Down Expand Up @@ -176,7 +176,7 @@ describe('state error focus', () => {
sm.transitions[0] = { ...sm.transitions[0], fromCondition: 'unbound(this)' };

expect(lines(createMermaidDiagram(sm, 'TB', true, true))).toContain(
`unbound --> bound : ${dimmedMethod('<span class="state-cond state-cond-pre">unbound(this)</span><br/>bind')}`);
`unbound --> bound : ${dimmedMethod('<span class="state-cond state-cond-pre">[unbound(this)]</span> bind')}`);
});

it('only colours labels that match the called method exactly', () => {
Expand Down Expand Up @@ -247,6 +247,7 @@ describe('state error focus', () => {
' childrenAllowed --> maybeHasChildren : add',
' maybeHasChildren --> childrenAllowed : removeAllChildren',
' maybeHasChildren --> maybeHasChildren : getFirstChild',
'%% lj-self-loops: edge3',
].join('\n'));
});
});
Expand Down Expand Up @@ -392,3 +393,79 @@ describe('state errors on several state sets', () => {
expect(getStateErrorFocuses([tiling, other])).toEqual([]);
});
});

// Door from the multi-set state error issue: d.lock(); d.open(); where open() needs doorState closed and lockState unlocked
function door(): LJStateMachine[] {
const open = { calledMethod: 'open', receiver: 'd' };
return [
{
className: 'Door', stateSet: 0, stateSetName: 'doorState', states: ['closed', 'opened'],
initialTransitions: [{ to: 'closed' }],
transitions: [
{ from: 'closed', to: 'opened', label: 'open', fromCondition: 'lockState(this) == unlocked' },
{ from: 'closed', to: 'closed', label: 'lock', fromCondition: 'lockState(this) != locked' },
],
errorContext: { ...open, possibleStates: ['closed'], acceptedStates: ['closed'], validStates: [], invalidStates: [] },
},
{
className: 'Door', stateSet: 1, stateSetName: 'lockState', states: ['unlocked', 'locked'],
initialTransitions: [{ to: 'unlocked' }],
transitions: [
{ from: 'unlocked', to: 'unlocked', label: 'open', fromCondition: 'doorState(this) == closed' },
{ from: 'locked', to: 'unlocked', label: 'unlock' },
],
errorContext: { ...open, possibleStates: ['locked'], acceptedStates: ['unlocked'], validStates: [], invalidStates: ['locked'] },
},
];
}

describe('a state error on several state sets', () => {
const guard = (text: string) => `<span class="state-cond state-cond-pre">[${text}]</span>`;
const unmet = (text: string) => `<span class="lj-unmet-condition">${text}</span>`;

it('puts the state set in a wrong state first', () => {
const [doorState, lockState] = door();
expect(orderStateMachines([doorState, lockState])).toEqual([lockState, doorState]);
});

it('marks the call in the state set in a wrong state, and its unmet condition in the others', () => {
const sms = door();
const [doorState, lockState] = sms.map(sm => lines(createMermaidDiagram(sm, 'TB', true, false, sms)));

expect(lockState).toContain(`unlocked --> unlocked : ${guard('doorState(this) == closed')} ${calledMethod('open')}`);
expect(doorState).toContain(`closed --> opened : ${guard(unmet('lockState(this) == unlocked'))} open`);
expect(doorState).toContain(`closed --> closed : ${guard('lockState(this) != locked')} lock`);
// the unmet condition stays when conditions are collapsed
expect(lines(createMermaidDiagram(sms[0], 'TB', false, false, sms)))
.toEqual(expect.arrayContaining([`closed --> opened : ${guard(unmet('lockState(this) == unlocked'))} open`, 'closed --> closed : lock']));
// only the clauses the actual state does not meet are marked
sms[0].transitions[0].fromCondition = 'lockState(this) == unlocked && (size(this) > 0 || ready(this))';
expect(lines(createMermaidDiagram(sms[0], 'TB', true, false, sms)))
.toContain(`closed --> opened : ${guard(`${unmet('lockState(this) == unlocked')} &amp;&amp; (size(this) &gt; 0 || ready(this))`)} open`);
// without a condition on the state set in a wrong state, the call stays marked
sms[0].transitions[0].fromCondition = 'size(this) > 0';
expect(lines(createMermaidDiagram(sms[0], 'TB', false, false, sms))).toContain(`closed --> opened : ${calledMethod('open')}`);
});

it('draws a transition that keeps its state as a round loop', async () => {
document.body.innerHTML = '<div class="diagram-container"><div id="diagram-wrapper"><pre class="mermaid"></pre></div></div>';
const pre = document.querySelector('.mermaid')!;
pre.textContent = createMermaidDiagram(door()[1], 'TB');
// ELK's curl on the left side of the state, from its top to its bottom
const points = [{ x: 100, y: 10 }, { x: 85, y: 15 }, { x: 100, y: 20 }];
const run = vi.fn(async () => {
pre.innerHTML = '<svg><path class="transition" data-id="edge1"></path></svg>';
Object.assign(pre.querySelector('path')!, {
getTotalLength: () => 2,
getPointAtLength: (at: number) => points[at],
});
});

await renderMermaidDiagram(document, { mermaid: { run } });

const loop = document.querySelector('path.lj-self-loop')!;
expect(loop.getAttribute('data-id')).toBe('edge1');
// as far out as the curl
expect(loop.getAttribute('d')).toBe('M100,10 C80,0 80,30 100,20');
});
});
27 changes: 24 additions & 3 deletions client/src/webview/diagram-focus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,33 @@ export function getRejectedCallText(call: Pick<StateErrorFocus, 'calledMethod' |
}

/**
* Orders the state machines of a class so that those of the state sets with the state error come first
* Orders the state machines of a class so that those of the state sets with the state error come first,
* starting with those whose state does not allow the call
* @param sms the state machines, one per state set, in declaration order
* @returns the state machines with the state error, then the others, each in declaration order
* @returns the state machines in a wrong state, then the others with the state error, then the rest, each in declaration order
*/
export function orderStateMachines(sms: LJStateMachine[]): LJStateMachine[] {
return [...sms.filter(sm => sm.errorContext), ...sms.filter(sm => !sm.errorContext)];
const rank = (sm: LJStateMachine) => hasWrongState(sm) ? 0 : sm.errorContext ? 1 : 2;
return [...sms].sort((a, b) => rank(a) - rank(b));
}

/**
* Whether the receiver may be in a state of this state set that does not allow the call
*/
function hasWrongState(sm: LJStateMachine): boolean {
return (getStateErrorFocus(sm)?.invalidStates.length ?? 0) > 0;
}

/**
* Gets the other state sets whose state rejects the call, when every possible state of this one allows it
* @param sm the state machine of this state set
* @param sms the state machines of the class, one per state set
* @returns the state machines in a wrong state, empty when this one may be in a wrong state or its state is unknown
*/
export function getRejectingStateSets(sm: LJStateMachine, sms: LJStateMachine[]): LJStateMachine[] {
const focus = getStateErrorFocus(sm);
if (!focus || focus.unknownState || focus.invalidStates.length > 0) return [];
return sms.filter(other => other !== sm && hasWrongState(other));
}

/**
Expand Down
Loading
Loading