This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git
The following commit(s) were added to refs/heads/main by this push:
new 29d7cd6cff test(frontend): cover the joint graph wrapper's layout and
co-editor paths (#7509)
29d7cd6cff is described below
commit 29d7cd6cff43542d11effd970ecfc9f1ba78ce11
Author: Xinyuan Lin <[email protected]>
AuthorDate: Mon Aug 10 10:05:10 2026 -0700
test(frontend): cover the joint graph wrapper's layout and co-editor paths
(#7509)
### What changes were proposed in this PR?
`joint-graph-wrapper.ts` sat at 76.4% lines and **50.6% branches**. The
branch number is the one
that mattered — half its conditions had only ever been taken one way.
Adds 20 tests to the existing spec, appended as new blocks rather than
edits so a rebase stays
cheap (#6489 also touches this file). Covered: absolute positioning, the
link-cell change stream,
auto layout, the co-editor presence rings including re-padding when one
is deleted, the editing
banner, and the guard clauses for missing and wrong-typed cells.
| | Before | After |
|---|---|---|
| Lines | 220/288 (76.4%) | **282/288 (97.91%)** |
| Branches | 43/85 (50.6%) | **82/85 (96.47%)** |
| Functions | 98/111 (88.3%) | 107/111 (96.39%) |
### Verification
46 mutations were applied and reverted, 45 red on the first pass. One
survived — the
`currentStrokeIds.includes(highlightIdToDelete)` guard — and the test
was strengthened to assert
ring **order** after a ghost delete, which kills it.
Review then found two more problems, both fixed:
- **A test whose name outran its assertions.** `"removeCurrentEditing
hides the banner and stops
the animation"` could not fail on the second half:
`removeCurrentEditing` blanks the banner text
first, so the interval body's ownership check is permanently false and
advancing the timers is a
no-op whether or not the interval was cleared. `clearInterval` is
already pinned by a
pre-existing test, so the unfalsifiable half was removed rather than
propped up.
- **A comment that misdescribed its own branch.** It claimed a link
exercised the `|| 0` fallback in
`getCellLayer`; a link's `z` is explicitly 0, so it only proves a
present zero survives.
### Deliberately not included
`getCellLayer`'s `|| 0` fallback (line 752) is **unreachable**, and no
test pins it. Mutating it to
`?? -1` survives, and the reason is structural rather than a missing
test: joint's `Graph.addCell`
sets `z = maxZIndex() + 1` on any cell that arrives without one, and
`getCellLayer` throws for a
cell that is not in the graph — so `attributes.z` is never `undefined`
where it is read. Deleting
the `|| 0` outright does not compile (`z` is `number | undefined`), so
it is load-bearing for typing
only. That is recorded in the spec so the next reader does not re-derive
it.
Also left alone: the link-breakpoint members.
`jointLinkBreakpointShowStream` and
`jointLinkBreakpointHideStream` are private Subjects never `.next()`-ed
anywhere in `frontend/src`,
and `linksWithBreakpoints` is declared and read but never written.
No production file is touched.
### Any related issues, documentation, discussions?
Closes #7508
### How was this PR tested?
```
npx ng test --watch=false --include="**/joint-graph-wrapper.spec.ts"
```
```
Test Files 1 passed (1)
Tests 67 passed (67)
```
20 new on top of the existing 47. `yarn format:ci` passes.
### Was this PR authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Opus 5)
Co-authored-by: Meng Wang <[email protected]>
---
.../model/joint-graph-wrapper.spec.ts | 424 ++++++++++++++++++++-
1 file changed, 423 insertions(+), 1 deletion(-)
diff --git
a/frontend/src/app/workspace/service/workflow-graph/model/joint-graph-wrapper.spec.ts
b/frontend/src/app/workspace/service/workflow-graph/model/joint-graph-wrapper.spec.ts
index c4bbd8b4f3..0d5c0db507 100644
---
a/frontend/src/app/workspace/service/workflow-graph/model/joint-graph-wrapper.spec.ts
+++
b/frontend/src/app/workspace/service/workflow-graph/model/joint-graph-wrapper.spec.ts
@@ -20,7 +20,11 @@
import { WorkflowActionService } from "./workflow-action.service";
import { UndoRedoService } from "../../undo-redo/undo-redo.service";
import { OperatorMetadataService } from
"../../operator-metadata/operator-metadata.service";
-import { JointUIService } from "../../joint-ui/joint-ui.service";
+import {
+ JointUIService,
+ operatorCoeditorChangedPropertyClass,
+ operatorCoeditorEditingClass,
+} from "../../joint-ui/joint-ui.service";
import { JointGraphWrapper } from "./joint-graph-wrapper";
import { TestBed } from "@angular/core/testing";
import { marbles } from "rxjs-marbles";
@@ -1139,4 +1143,422 @@ describe("JointGraphWrapperService", () => {
expect(() => jointGraphWrapper.removePropertyChanged(coeditor,
mockScanPredicate.operatorID)).not.toThrow();
});
});
+
+ describe("guard clauses for missing, wrong-typed and unhighlighted cells",
() => {
+ const addScanAndResult = (): void => {
+
jointGraph.addCell(jointUIService.getJointOperatorElement(mockScanPredicate,
mockPoint));
+
jointGraph.addCell(jointUIService.getJointOperatorElement(mockResultPredicate,
mockPoint));
+ };
+ const addScanResultLink = (): void => {
+ jointGraph.addCell(JointUIService.getJointLinkCell(mockScanResultLink));
+ };
+
+ it("setElementPosition rejects an unknown id and a link id", () => {
+ addScanAndResult();
+ addScanResultLink();
+
+ expect(() => jointGraphWrapper.setElementPosition("no-such-cell", 10,
10)).toThrowError(
+ "element with ID no-such-cell doesn't exist"
+ );
+ expect(() =>
jointGraphWrapper.setElementPosition(mockScanResultLink.linkID, 10,
10)).toThrowError(
+ `${mockScanResultLink.linkID} is not an element`
+ );
+ });
+
+ it("setAbsolutePosition moves an element and rejects an unknown id and a
link id", () => {
+ addScanAndResult();
+ addScanResultLink();
+
+ jointGraphWrapper.setAbsolutePosition(mockScanPredicate.operatorID, 42,
43);
+
expect(jointGraphWrapper.getElementPosition(mockScanPredicate.operatorID)).toEqual({
x: 42, y: 43 });
+
+ expect(() => jointGraphWrapper.setAbsolutePosition("no-such-cell", 1,
1)).toThrowError(
+ "element with ID no-such-cell doesn't exist"
+ );
+ expect(() =>
jointGraphWrapper.setAbsolutePosition(mockScanResultLink.linkID, 1,
1)).toThrowError(
+ `${mockScanResultLink.linkID} is not an element`
+ );
+ });
+
+ it("highlightOperators rejects an id that is not in the graph", () => {
+ expect(() =>
jointGraphWrapper.highlightOperators("no-such-operator")).toThrowError(
+ "element with ID no-such-operator doesn't exist"
+ );
+ expect(jointGraphWrapper.getCurrentHighlightedOperatorIDs()).toEqual([]);
+ });
+
+ it("highlightLink rejects an unknown id and ignores an already highlighted
link", () => {
+ addScanAndResult();
+ addScanResultLink();
+ const highlighted: string[][] = [];
+ const subscription =
jointGraphWrapper.getLinkHighlightStream().subscribe(ids =>
highlighted.push([...ids]));
+
+ expect(() =>
jointGraphWrapper.highlightLink("no-such-link")).toThrowError(
+ "link with ID no-such-link doesn't exist"
+ );
+
+ jointGraphWrapper.highlightLink(mockScanResultLink.linkID);
+ jointGraphWrapper.highlightLink(mockScanResultLink.linkID);
+ expect(highlighted).toEqual([[mockScanResultLink.linkID]]);
+
+ subscription.unsubscribe();
+ });
+
+ it("unhighlightLink and unhighlightOperators stay silent for cells that
are not highlighted", () => {
+ addScanAndResult();
+ addScanResultLink();
+ const unhighlightedLinks: string[][] = [];
+ const unhighlightedOperators: string[][] = [];
+ const onLink =
jointGraphWrapper.getLinkUnhighlightStream().subscribe(ids =>
unhighlightedLinks.push([...ids]));
+ const onOperator = jointGraphWrapper
+ .getJointOperatorUnhighlightStream()
+ .subscribe(ids => unhighlightedOperators.push([...ids]));
+
+ jointGraphWrapper.unhighlightLink(mockScanResultLink.linkID);
+ jointGraphWrapper.unhighlightOperators(mockScanPredicate.operatorID);
+
+ expect(unhighlightedLinks).toEqual([]);
+ expect(unhighlightedOperators).toEqual([]);
+
+ onLink.unsubscribe();
+ onOperator.unsubscribe();
+ });
+
+ it("highlightCommentBoxes stays silent when every requested box is already
highlighted", () => {
+ jointGraph.addCell(jointUIService.getCommentElement(mockCommentBox));
+ const highlighted: string[][] = [];
+ const subscription = jointGraphWrapper
+ .getJointCommentBoxHighlightStream()
+ .subscribe(ids => highlighted.push([...ids]));
+
+ jointGraphWrapper.highlightCommentBoxes(mockCommentBox.commentBoxID);
+ jointGraphWrapper.highlightCommentBoxes(mockCommentBox.commentBoxID);
+
+ expect(highlighted).toEqual([[mockCommentBox.commentBoxID]]);
+ subscription.unsubscribe();
+ });
+
+ it("getCellLayer reports the layer JointUIService assigned", () => {
+ addScanAndResult();
+ addScanResultLink();
+
+ // JointUIService assigns z=1 to operators and z=0 to links. The `|| 0`
fallback in
+ // getCellLayer is unreachable and is deliberately not pinned: joint's
Graph.addCell sets
+ // `z = maxZIndex() + 1` on any cell that arrives without one, and
getCellLayer throws for a
+ // cell that is not in the graph — so `attributes.z` is never undefined
by the time it reads
+ // it. The link's zero is an explicitly assigned zero, not the fallback.
+
expect(jointGraphWrapper.getCellLayer(mockScanPredicate.operatorID)).toBe(1);
+
expect(jointGraphWrapper.getCellLayer(mockScanResultLink.linkID)).toBe(0);
+ });
+
+ it("removing a highlighted cell straight from the graph unhighlights it",
() => {
+ addScanAndResult();
+ addScanResultLink();
+ jointGraphWrapper.setMultiSelectMode(true);
+ jointGraphWrapper.highlightOperators(mockScanPredicate.operatorID);
+ jointGraphWrapper.highlightLinks(mockScanResultLink.linkID);
+
+ jointGraph.getCell(mockScanResultLink.linkID).remove();
+ expect(jointGraphWrapper.getCurrentHighlightedLinkIDs()).toEqual([]);
+
expect(jointGraphWrapper.getCurrentHighlightedOperatorIDs()).toEqual([mockScanPredicate.operatorID]);
+
+ jointGraph.getCell(mockScanPredicate.operatorID).remove();
+ expect(jointGraphWrapper.getCurrentHighlightedOperatorIDs()).toEqual([]);
+ });
+
+ it("getElementPositionChangeEvent carries the previous position across
consecutive moves", () => {
+
jointGraph.addCell(jointUIService.getJointOperatorElement(mockScanPredicate,
mockPoint));
+ const moves: { oldPosition: { x: number; y: number }; newPosition: { x:
number; y: number } }[] = [];
+ const subscription = jointGraphWrapper
+ .getElementPositionChangeEvent()
+ .subscribe(event => moves.push({ oldPosition: event.oldPosition,
newPosition: event.newPosition }));
+
+ const element = jointGraph.getCell(mockScanPredicate.operatorID) as
joint.dia.Element;
+ element.position(100, 200);
+ element.position(150, 200); // only x moves
+ element.position(150, 250); // only y moves
+
+ expect(moves).toEqual([
+ { oldPosition: mockPoint, newPosition: { x: 100, y: 200 } },
+ { oldPosition: { x: 100, y: 200 }, newPosition: { x: 150, y: 200 } },
+ { oldPosition: { x: 150, y: 200 }, newPosition: { x: 150, y: 250 } },
+ ]);
+ subscription.unsubscribe();
+ });
+
+ it("getElementPositionChangeEvent errors for an element the wrapper never
saw being added", () => {
+ // the position map is only filled from the cell-add stream, so a cell
that predates
+ // the wrapper has no recorded position and the stream must fail loudly
instead of
+ // reporting a bogus old position.
+ const preexistingGraph = new joint.dia.Graph();
+
preexistingGraph.addCell(jointUIService.getJointOperatorElement(mockScanPredicate,
mockPoint));
+ const lateWrapper = new JointGraphWrapper(preexistingGraph);
+
+ let error: unknown;
+ const subscription = lateWrapper
+ .getElementPositionChangeEvent()
+ .subscribe({ error: (e: unknown) => (error = e) });
+ (preexistingGraph.getCell(mockScanPredicate.operatorID) as
joint.dia.Element).position(1, 2);
+
+ expect(error).toEqual(
+ new Error(`internal error: cannot find element position for
${mockScanPredicate.operatorID}`)
+ );
+ subscription.unsubscribe();
+ });
+ });
+
+ describe("link endpoint changes", () => {
+ it("getJointLinkCellChangeStream emits the link whose endpoint was
re-attached", () => {
+
jointGraph.addCell(jointUIService.getJointOperatorElement(mockScanPredicate,
mockPoint));
+
jointGraph.addCell(jointUIService.getJointOperatorElement(mockResultPredicate,
mockPoint));
+
jointGraph.addCell(jointUIService.getJointOperatorElement(mockSentimentPredicate,
mockPoint));
+ jointGraph.addCell(JointUIService.getJointLinkCell(mockScanResultLink));
+
+ const changed: string[] = [];
+ const subscription = jointGraphWrapper
+ .getJointLinkCellChangeStream()
+ .subscribe(link => changed.push(link.id.toString()));
+
+ const link = jointGraph.getCell(mockScanResultLink.linkID) as
joint.dia.Link;
+ link.set("target", { id: mockSentimentPredicate.operatorID, port:
"input-0" });
+ link.set("source", { x: 10, y: 20 }); // detaching an end is a change too
+
+ expect(changed).toEqual([mockScanResultLink.linkID,
mockScanResultLink.linkID]);
+ subscription.unsubscribe();
+ });
+ });
+
+ describe("auto layout", () => {
+ it("lays operators out left to right and leaves region elements where they
are", () => {
+ // regions are decorations drawn around operators; feeding them to dagre
would move
+ // them independently of the operators they wrap.
+ const Region = joint.dia.Element.define("region", {}, { markup: [{
tagName: "path", selector: "body" }] });
+
jointGraph.addCell(jointUIService.getJointOperatorElement(mockScanPredicate,
mockPoint));
+
jointGraph.addCell(jointUIService.getJointOperatorElement(mockResultPredicate,
mockPoint));
+ jointGraph.addCell(JointUIService.getJointLinkCell(mockScanResultLink));
+ const region = new Region({ position: { x: 500, y: 500 }, size: { width:
10, height: 10 } });
+ jointGraph.addCell(region);
+
+ jointGraphWrapper.autoLayoutJoint();
+
+ const scan =
jointGraphWrapper.getElementPosition(mockScanPredicate.operatorID);
+ const result =
jointGraphWrapper.getElementPosition(mockResultPredicate.operatorID);
+ expect(scan.x).toBeLessThan(result.x);
+ expect(region.position()).toEqual({ x: 500, y: 500 });
+ });
+ });
+
+ describe("with a joint paper attached", () => {
+ const alice: Coeditor = {
+ clientId: "client-alice",
+ uid: 1,
+ name: "Alice",
+ email: "[email protected]",
+ role: Role.REGULAR,
+ comment: "",
+ joiningReason: "",
+ color: "#ff0000",
+ };
+ const bob: Coeditor = { ...alice, clientId: "client-bob", name: "Bob",
color: "#00ff00" };
+
+ let paper: joint.dia.Paper;
+ let paperHost: HTMLDivElement;
+
+ beforeEach(() => {
+ paperHost = document.createElement("div");
+ document.body.appendChild(paperHost);
+ paper = jointGraphWrapper.attachMainJointPaper({ el: paperHost, width:
600, height: 400 });
+ });
+
+ afterEach(() => {
+ paper.remove();
+ paperHost.remove();
+ });
+
+ const addScanOperator = (): void => {
+
jointGraph.addCell(jointUIService.getJointOperatorElement(mockScanPredicate,
mockPoint));
+ };
+ const editingBanner = (): { text?: string; fill?: string; visibility?:
string } =>
+ (
+ paper.getModelById(mockScanPredicate.operatorID).attributes.attrs as
Record<
+ string,
+ { text?: string; fill?: string; visibility?: string }
+ >
+ )[`.${operatorCoeditorEditingClass}`];
+
+ it("attachMainJointPaper binds the wrapper's own graph and publishes the
paper", () => {
+ expect(paper.model).toBe(jointGraph);
+ expect(jointGraphWrapper.getMainJointPaper()).toBe(paper);
+
+ // the stream is a ReplaySubject, so a subscriber that arrives after the
attach still gets it
+ const attached: joint.dia.Paper[] = [];
+ const subscription =
jointGraphWrapper.getMainJointPaperAttachedStream().subscribe(p =>
attached.push(p));
+ expect(attached).toHaveLength(1);
+ expect(attached[0]).toBe(paper);
+ subscription.unsubscribe();
+ });
+
+ it("an async context defers highlight events and switches the paper to
async rendering", () => {
+ addScanOperator();
+
jointGraph.addCell(jointUIService.getJointOperatorElement(mockResultPredicate,
mockPoint));
+ jointGraphWrapper.setMultiSelectMode(true);
+ const highlighted: string[][] = [];
+ const subscription = jointGraphWrapper
+ .getJointOperatorHighlightStream()
+ .subscribe(ids => highlighted.push([...ids]));
+ const updateViews = vi.spyOn(paper, "updateViews");
+
+ expect(paper.options.async).toBe(false);
+ jointGraphWrapper.jointGraphContext.withContext({ async: true }, () => {
+ expect(paper.options.async).toBe(true);
+ jointGraphWrapper.highlightOperators(mockScanPredicate.operatorID);
+ expect(highlighted).toEqual([]);
+ });
+
+ // leaving the context restores synchronous rendering and forces the
deferred views out
+ expect(paper.options.async).toBe(false);
+ expect(updateViews).toHaveBeenCalled();
+
+ // the buffered event is only drained by the next event emitted outside
the context
+ jointGraphWrapper.highlightOperators(mockResultPredicate.operatorID);
+ expect(highlighted).toEqual([[mockScanPredicate.operatorID],
[mockResultPredicate.operatorID]]);
+
+ // a synchronous context never deferred anything, so leaving it must not
force a repaint
+ updateViews.mockClear();
+ jointGraphWrapper.jointGraphContext.withContext({ async: false }, () => {
+ expect(paper.options.async).toBe(false);
+ });
+ expect(updateViews).not.toHaveBeenCalled();
+
+ updateViews.mockRestore();
+ subscription.unsubscribe();
+ });
+
+ it("an async context still buffers when no paper was ever attached", () =>
{
+ const bareGraph = new joint.dia.Graph();
+ const bareWrapper = new JointGraphWrapper(bareGraph);
+
bareGraph.addCell(jointUIService.getJointOperatorElement(mockScanPredicate,
mockPoint));
+ const highlighted: string[][] = [];
+ const subscription =
bareWrapper.getJointOperatorHighlightStream().subscribe(ids =>
highlighted.push([...ids]));
+
+ expect(() =>
+ bareWrapper.jointGraphContext.withContext({ async: true }, () =>
+ bareWrapper.highlightOperators(mockScanPredicate.operatorID)
+ )
+ ).not.toThrow();
+ expect(highlighted).toEqual([]);
+
+ subscription.unsubscribe();
+ });
+
+ it("addCoeditorOperatorHighlight stacks one ring per coeditor and ignores
duplicates", () => {
+ addScanOperator();
+ const view = paper.findViewByModel(mockScanPredicate.operatorID);
+ const rings = () =>
+ joint.highlighters.mask.get(view).map(stroke => ({ id: stroke.id,
padding: stroke.options.padding }));
+
+ jointGraphWrapper.addCoeditorOperatorHighlight(alice,
mockScanPredicate.operatorID);
+ jointGraphWrapper.addCoeditorOperatorHighlight(alice,
mockScanPredicate.operatorID);
+ jointGraphWrapper.addCoeditorOperatorHighlight(bob,
mockScanPredicate.operatorID);
+
+ // each additional coeditor gets a wider ring so all of them stay visible
+ expect(rings()).toEqual([
+ { id:
`coeditorHighlight_${alice.clientId}_${mockScanPredicate.operatorID}`, padding:
5 },
+ { id:
`coeditorHighlight_${bob.clientId}_${mockScanPredicate.operatorID}`, padding:
10 },
+ ]);
+ });
+
+ it("deleteCoeditorOperatorHighlight removes one ring and re-pads the ones
behind it", () => {
+ addScanOperator();
+ const view = paper.findViewByModel(mockScanPredicate.operatorID);
+ const rings = () =>
+ joint.highlighters.mask.get(view).map(stroke => ({ id: stroke.id,
padding: stroke.options.padding }));
+ jointGraphWrapper.addCoeditorOperatorHighlight(alice,
mockScanPredicate.operatorID);
+ jointGraphWrapper.addCoeditorOperatorHighlight(bob,
mockScanPredicate.operatorID);
+
+ // deleting a coeditor that never highlighted this operator must leave
the existing
+ // rings alone: the re-pad loop below removes and re-adds rings, which
reorders them.
+ jointGraphWrapper.deleteCoeditorOperatorHighlight(
+ { ...alice, clientId: "client-carol" },
+ mockScanPredicate.operatorID
+ );
+ expect(rings()).toEqual([
+ { id:
`coeditorHighlight_${alice.clientId}_${mockScanPredicate.operatorID}`, padding:
5 },
+ { id:
`coeditorHighlight_${bob.clientId}_${mockScanPredicate.operatorID}`, padding:
10 },
+ ]);
+
+ jointGraphWrapper.deleteCoeditorOperatorHighlight(alice,
mockScanPredicate.operatorID);
+
+ // Bob's ring moves inwards to close the gap Alice left behind
+ expect(rings()).toEqual([
+ { id:
`coeditorHighlight_${bob.clientId}_${mockScanPredicate.operatorID}`, padding: 5
},
+ ]);
+ });
+
+ it("setCurrentEditing writes the coeditor banner and animates its trailing
dots", () => {
+ addScanOperator();
+ vi.useFakeTimers();
+ const intervalId = jointGraphWrapper.setCurrentEditing(alice,
mockScanPredicate.operatorID);
+ try {
+ expect(editingBanner()).toMatchObject({
+ text: "Alice is viewing/editing...",
+ fill: alice.color,
+ visibility: "visible",
+ });
+
+ // the trailing dots cycle ... -> . -> .. -> ... on every tick
+ vi.advanceTimersByTime(300);
+ expect(editingBanner().text).toBe("Alice is viewing/editing.");
+ vi.advanceTimersByTime(300);
+ expect(editingBanner().text).toBe("Alice is viewing/editing..");
+ vi.advanceTimersByTime(300);
+ expect(editingBanner().text).toBe("Alice is viewing/editing...");
+
+ // a banner that belongs to somebody else must not be overwritten by
Alice's animation
+ paper
+ .getModelById(mockScanPredicate.operatorID)
+ .attr({ [`.${operatorCoeditorEditingClass}`]: { text: "Bob is
viewing/editing..." } });
+ vi.advanceTimersByTime(300);
+ expect(editingBanner().text).toBe("Bob is viewing/editing...");
+ } finally {
+ clearInterval(intervalId);
+ vi.useRealTimers();
+ }
+ });
+
+ it("removeCurrentEditing hides the banner", () => {
+ addScanOperator();
+ vi.useFakeTimers();
+ const intervalId = jointGraphWrapper.setCurrentEditing(alice,
mockScanPredicate.operatorID);
+ try {
+ jointGraphWrapper.removeCurrentEditing(alice,
mockScanPredicate.operatorID, intervalId);
+
+ expect(editingBanner()).toMatchObject({ text: "", visibility: "hidden"
});
+ } finally {
+ clearInterval(intervalId);
+ vi.useRealTimers();
+ }
+ });
+
+ it("set/removePropertyChanged show and hide the property-changed banner",
() => {
+ addScanOperator();
+ const banner = (): { text?: string; fill?: string; visibility?: string }
=>
+ (
+ paper.getModelById(mockScanPredicate.operatorID).attributes.attrs as
Record<
+ string,
+ { text?: string; fill?: string; visibility?: string }
+ >
+ )[`.${operatorCoeditorChangedPropertyClass}`];
+
+ jointGraphWrapper.setPropertyChanged(alice,
mockScanPredicate.operatorID);
+ expect(banner()).toMatchObject({
+ text: "Alice changed property!",
+ fill: alice.color,
+ visibility: "visible",
+ });
+
+ jointGraphWrapper.removePropertyChanged(alice,
mockScanPredicate.operatorID);
+ expect(banner()).toMatchObject({ text: "", visibility: "hidden" });
+ });
+ });
});