You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@texera.apache.org by gi...@apache.org on 2026/05/25 22:35:22 UTC
(texera) branch gh-readonly-queue/main/pr-5196-c435aa73434f64fbffe82d5242ded17b56952aab created (now 2153c17879)
This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a change to branch gh-readonly-queue/main/pr-5196-c435aa73434f64fbffe82d5242ded17b56952aab
in repository https://gitbox.apache.org/repos/asf/texera.git
at 2153c17879 test(frontend): cover joint-ui.service surface and remove dead code (#5196)
This branch includes the following new commits:
new 2153c17879 test(frontend): cover joint-ui.service surface and remove dead code (#5196)
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(texera) 01/01: test(frontend): cover joint-ui.service surface and remove dead code (#5196)
Posted by gi...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-5196-c435aa73434f64fbffe82d5242ded17b56952aab
in repository https://gitbox.apache.org/repos/asf/texera.git
commit 2153c17879ca4504da09d4d7dc9648d17f364aa0
Author: Yicong Huang <17...@users.noreply.github.com>
AuthorDate: Mon May 25 15:35:02 2026 -0700
test(frontend): cover joint-ui.service surface and remove dead code (#5196)
### What changes were proposed in this PR?
`joint-ui.service.spec.ts` had ~60 commented test lines plus an
`it.todo` placeholder; the commented tests called methods that have
since been removed from `JointUIService`, so they're unrevivable. Drop
them and add real tests for the public surface the spec was leaving
unverified — static helpers, the joint-paper-bound
state/color/visibility setters, the port-iteration loops,
`getJointOperatorElement`, and `changeOperatorStatistics`.
Writing those tests made four pieces of stale `joint-ui.service.ts`
itself obvious: a static tooltip-style helper with zero callers (its
paired element was removed years ago), two dead `originalName` chains
inside `changeOperatorStatistics` (computed, never read), and a `if
(!element) return` guard at the very end of `unfoldOperatorDetails`
(no-op — nothing follows it). Removed.
### Any related issues, documentation, discussions?
Closes #5195. Same shape as #5194.
### How was this PR tested?
`yarn ng test --watch=false --include=…` runs 56 tests, all green. `yarn
lint` and `yarn format:ci` both clean.
### Was this PR authored or co-authored using generative AI tooling?
Generated-by: Claude Opus 4.7
---
.../service/joint-ui/joint-ui.service.spec.ts | 850 +++++++++++++--------
.../workspace/service/joint-ui/joint-ui.service.ts | 64 +-
2 files changed, 535 insertions(+), 379 deletions(-)
diff --git a/frontend/src/app/workspace/service/joint-ui/joint-ui.service.spec.ts b/frontend/src/app/workspace/service/joint-ui/joint-ui.service.spec.ts
index ccefd937de..87b5acd924 100644
--- a/frontend/src/app/workspace/service/joint-ui/joint-ui.service.spec.ts
+++ b/frontend/src/app/workspace/service/joint-ui/joint-ui.service.spec.ts
@@ -17,327 +17,26 @@
* under the License.
*/
-// import { mockResultPredicate, mockPoint } from './../workflow-graph/model/mock-workflow-data';
-// import { TestBed, inject } from '@angular/core/testing';
-// import * as joint from 'jointjs';
-
-// import { JointUIService, deleteButtonPath, sourceOperatorHandle, targetOperatorHandle } from './joint-ui.service';
-// import { OperatorMetadataService } from '../operator-metadata/operator-metadata.service';
-// import { StubOperatorMetadataService } from '../operator-metadata/stub-operator-metadata.service';
-// import { mockScanPredicate, mockSentimentPredicate } from '../workflow-graph/model/mock-workflow-data';
-// import { mockScanStatistic1, mockScanStatistic2 } from '../workflow-status/mock-workflow-status';
-
-// describe('JointUIService', () => {
-// let service: JointUIService;
-
-// beforeEach(() => {
-// TestBed.configureTestingModule({
-// providers: [
-// JointUIService,
-// { provide: OperatorMetadataService, useClass: StubOperatorMetadataService },
-// ],
-// });
-// service = TestBed.get(JointUIService);
-// });
-
-// it('should be created', inject([JointUIService], (injectedService: JointUIService) => {
-// expect(injectedService).toBeTruthy();
-// }));
-
-// /**
-// * Check if the getJointOperatorElement() can successfully creates a JointJS Element
-// */
-// it('should create an JointJS Element successfully when the function is called', () => {
-// const result = service.getJointOperatorElement(
-// mockScanPredicate, mockPoint);
-// expect(result).toBeTruthy();
-// });
-
-// /**
-// * Check if the error in getJointOperatorElement() is correctly thrown
-// */
-// it('should throw an error with an non existing operator', () => {
-// expect(() => {
-// service.getJointOperatorElement(
-// {
-// operatorID: 'nonexistOperator',
-// operatorType: 'nonexistOperatorType',
-// operatorProperties: {},
-// inputPorts: [],
-// outputPorts: [],
-// showAdvanced: true
-// },
-// mockPoint
-// );
-// }).toThrowError(new RegExp(`doesn't exist`));
-// });
-
-// /**
-// * Check if the getJointTooltipElement() can successfully creates a JointJS Element
-// */
-// it('should create an JointJS Element successfully when the function is called', () => {
-// const result = service.getJointOperatorStatusTooltipElement(
-// mockScanPredicate, mockPoint);
-// expect(result).toBeTruthy();
-// });
-
-// /**
-// * Check if the error in getJointTooltipElement() is correctly thrown
-// */
-// it('should throw an error with an non existing operator', () => {
-// expect(() => {
-// service.getJointOperatorStatusTooltipElement(
-// {
-// operatorID: 'nonexistOperator',
-// operatorType: 'nonexistOperatorType',
-// operatorProperties: {},
-// inputPorts: [],
-// outputPorts: [],
-// showAdvanced: true
-// },
-// mockPoint
-// );
-// }).toThrowError(new RegExp(`doesn't exist`));
-// });
-
-// /**
-// * Check if showTooltip/hideTooltip works properly
-// */
-// it('should reveal/hide tooltip and its content when showToolTip/hideTooltip is called', () => {
-// // creating a JointJS graph with one operator and its tooltip
-// const jointGraph = new joint.dia.Graph();
-// const jointPaperOptions: joint.dia.Paper.Options = {model: jointGraph};
-// const paper = new joint.dia.Paper(jointPaperOptions);
-
-// jointGraph.addCell([
-// service.getJointOperatorElement(
-// mockScanPredicate,
-// mockPoint
-// ),
-// service.getJointOperatorStatusTooltipElement(
-// mockScanPredicate,
-// mockPoint
-// )
-// ]);
-// // tooltip should not be shown when operator is just created
-// // disply attr should be none
-// const tooltipId = JointUIService.getOperatorStatusTooltipElementID(mockScanPredicate.operatorID);
-// const graph_tooltip1 = jointGraph.getCell(tooltipId);
-// expect(graph_tooltip1.attr('polygon')['display']).toEqual('none');
-// expect(graph_tooltip1.attr('#operatorCount')['display']).toEqual('none');
-// expect(graph_tooltip1.attr('#operatorSpeed')['display']).toEqual('none');
-// // showTooltip removes display == none attr to show tooltip
-// service.showOperatorStatusToolTip(paper, tooltipId);
-// expect(graph_tooltip1.attr('polygon')['display']).toBeUndefined();
-// expect(graph_tooltip1.attr('#operatorCount')['display']).toBeUndefined();
-// expect(graph_tooltip1.attr('#operatorSpeed')['display']).toBeUndefined();
-// // hideTooltip adds display == none attr to hide tooltip
-// service.hideOperatorStatusToolTip(paper, tooltipId);
-// expect(graph_tooltip1.attr('polygon')['display']).toEqual('none');
-// expect(graph_tooltip1.attr('#operatorCount')['display']).toEqual('none');
-// expect(graph_tooltip1.attr('#operatorSpeed')['display']).toEqual('none');
-// });
-
-// /**
-// * check if tooltip content can be updated properly
-// */
-// it('should update the content in the tooltip when changeOperatorTooltipInfo is called', () => {
-// // creating a JointJS graph with one operator and its tooltip
-// const jointGraph = new joint.dia.Graph();
-// const jointPaperOptions: joint.dia.Paper.Options = {model: jointGraph};
-// const paper = new joint.dia.Paper(jointPaperOptions);
-
-// jointGraph.addCell([
-// service.getJointOperatorElement(
-// mockScanPredicate,
-// mockPoint
-// ),
-// service.getJointOperatorStatusTooltipElement(
-// mockScanPredicate,
-// mockPoint
-// )
-// ]);
-// const tooltipId = JointUIService.getOperatorStatusTooltipElementID(mockScanPredicate.operatorID);
-// const graph_tooltip = jointGraph.getCell(tooltipId);
-// // tooltip should not contain any content when created
-// expect(graph_tooltip.attr('#operatorCount')['text']).toBeUndefined();
-// expect(graph_tooltip.attr('#operatorSpeed')['text']).toBeUndefined();
-// // updating it with mock statistics
-// service.changeOperatorStatusTooltipInfo(paper, tooltipId, mockScanStatistic1);
-// expect(graph_tooltip.attr('#operatorCount')['text']).toEqual('Output:' + mockScanStatistic1.outputCount + ' tuples');
-// expect(graph_tooltip.attr('#operatorSpeed')['text']).toEqual('Speed:' + mockScanStatistic1.speed + ' tuples/s');
-// // updating it with another mock statistics
-// service.changeOperatorStatusTooltipInfo(paper, tooltipId, mockScanStatistic2);
-// expect(graph_tooltip.attr('#operatorCount')['text']).toEqual('Output:' + mockScanStatistic2.outputCount + ' tuples');
-// expect(graph_tooltip.attr('#operatorSpeed')['text']).toEqual('Speed:' + mockScanStatistic2.speed + ' tuples/s');
-// });
-
-// it('should change the operator state name and color when changeOperatorStates is called', () => {
-// // creating a JointJS graph with one operator and its tooltip
-// const jointGraph = new joint.dia.Graph();
-// const jointPaperOptions: joint.dia.Paper.Options = {model: jointGraph};
-// const paper = new joint.dia.Paper(jointPaperOptions);
-
-// jointGraph.addCell(
-// service.getJointOperatorElement(
-// mockScanPredicate,
-// mockPoint
-// ));
-
-// // operator state name and color should be changed correctly
-// const graph_operator = jointGraph.getCell(mockScanPredicate.operatorID);
-// expect(graph_operator.attr('#operatorStates')['text']).toEqual('Ready');
-// expect(graph_operator.attr('#operatorStates')['fill']).toEqual('green');
-// service.changeOperatorStates(paper, mockScanPredicate.operatorID, OperatorStates.Initializing);
-// expect(graph_operator.attr('#operatorStates')['text']).toEqual('Initializing');
-// expect(graph_operator.attr('#operatorStates')['fill']).toEqual('orange');
-// service.changeOperatorStates(paper, mockScanPredicate.operatorID, OperatorStates.Running);
-// expect(graph_operator.attr('#operatorStates')['text']).toEqual('Running');
-// expect(graph_operator.attr('#operatorStates')['fill']).toEqual('orange');
-// service.changeOperatorStates(paper, mockScanPredicate.operatorID, OperatorStates.Pausing);
-// expect(graph_operator.attr('#operatorStates')['text']).toEqual('Pausing');
-// expect(graph_operator.attr('#operatorStates')['fill']).toEqual('red');
-// service.changeOperatorStates(paper, mockScanPredicate.operatorID, OperatorStates.Paused);
-// expect(graph_operator.attr('#operatorStates')['text']).toEqual('Paused');
-// expect(graph_operator.attr('#operatorStates')['fill']).toEqual('red');
-// service.changeOperatorStates(paper, mockScanPredicate.operatorID, OperatorStates.Completed);
-// expect(graph_operator.attr('#operatorStates')['text']).toEqual('Completed');
-// expect(graph_operator.attr('#operatorStates')['fill']).toEqual('green');
-// });
-
-// /**
-// * Check if the number of inPorts and outPorts created by getJointOperatorElement()
-// * matches the port number specified by the operator metadata
-// */
-// it('should create correct number of inPorts and outPorts based on operator metadata', () => {
-// const element1 = service.getJointOperatorElement(mockScanPredicate, mockPoint);
-// const element2 = service.getJointOperatorElement(mockSentimentPredicate, mockPoint);
-// const element3 = service.getJointOperatorElement(mockResultPredicate, mockPoint);
-
-// const inPortCount1 = element1.getPorts().filter(port => port.group === 'in').length;
-// const outPortCount1 = element1.getPorts().filter(port => port.group === 'out').length;
-// const inPortCount2 = element2.getPorts().filter(port => port.group === 'in').length;
-// const outPortCount2 = element2.getPorts().filter(port => port.group === 'out').length;
-// const inPortCount3 = element3.getPorts().filter(port => port.group === 'in').length;
-// const outPortCount3 = element3.getPorts().filter(port => port.group === 'out').length;
-
-// expect(inPortCount1).toEqual(0);
-// expect(outPortCount1).toEqual(1);
-// expect(inPortCount2).toEqual(1);
-// expect(outPortCount2).toEqual(1);
-// expect(inPortCount3).toEqual(1);
-// expect(outPortCount3).toEqual(0);
-
-// });
-
-// /**
-// * Check if the custom attributes / svgs are correctly used by the JointJS graph
-// */
-// it('should apply the custom SVG styling to the JointJS element', () => {
-
-// const graph = new joint.dia.Graph();
-// // operator and its tooltip element should be added together
-// graph.addCell([
-// service.getJointOperatorElement(
-// mockScanPredicate,
-// mockPoint
-// ),
-// service.getJointOperatorStatusTooltipElement(
-// mockScanPredicate,
-// mockPoint
-// )
-// ]);
-// graph.addCell([
-// service.getJointOperatorElement(
-// mockResultPredicate,
-// { x: 500, y: 100 }
-// ),
-// service.getJointOperatorStatusTooltipElement(
-// mockResultPredicate,
-// { x: 500, y: 100 }
-// )
-// ]);
-
-// const link = JointUIService.getJointLinkCell({
-// linkID: 'link-1',
-// source: { operatorID: 'operator1', portID: 'out0' },
-// target: { operatorID: 'operator2', portID: 'in0' },
-// });
-
-// graph.addCell(link);
-
-// const graph_operator1 = graph.getCell(mockScanPredicate.operatorID);
-// const graph_operator2 = graph.getCell(mockResultPredicate.operatorID);
-// const graph_link = graph.getLinks()[0];
-// const graph_tooltip1 = graph.getCell(JointUIService.getOperatorStatusTooltipElementID(mockScanPredicate.operatorID));
-
-// // testing getCustomTooltipStyleAttrs()
-// // style: {'pointer-events': 'none'} makes tooltip unselectable thus not draggable
-// expect(graph_tooltip1.attr('polygon')).toEqual({
-// fill: '#FFFFFF', 'follow-scale': true, stroke: 'purple', 'stroke-width': '2',
-// rx: '5px', ry: '5px', refPoints: '0,30 150,30 150,120 85,120 75,150 65,120 0,120',
-// display: 'none', style: {'pointer-events': 'none'}
-// });
-// expect(graph_tooltip1.attr('#operatorCount')).toEqual({
-// fill: '#595959', 'font-size': '12px', ref: 'polygon',
-// 'y-alignment': 'middle',
-// 'x-alignment': 'left',
-// 'ref-x': .05, 'ref-y': .2,
-// display: 'none', style: {'pointer-events': 'none'}
-// });
-// expect(graph_tooltip1.attr('#operatorSpeed')).toEqual({
-// fill: '#595959',
-// ref: 'polygon',
-// 'x-alignment': 'left',
-// 'font-size': '12px',
-// 'ref-x': .05, 'ref-y': .5,
-// display: 'none', style: {'pointer-events': 'none'}
-// });
-
-// // testing getCustomOperatorStyleAttrs()
-// expect(graph_operator1.attr('#operatorStates')).toEqual({
-// text: 'Ready' , fill: 'green', 'font-size': '14px', 'visible' : false,
-// 'ref-x': 0.5, 'ref-y': -10, ref: 'rect', 'y-alignment': 'middle', 'x-alignment': 'middle'
-// });
-// expect(graph_operator1.attr('rect')).toEqual(
-// { fill: '#FFFFFF', 'follow-scale': true, stroke: 'red', 'stroke-width': '2',
-// rx: '5px', ry: '5px' }
-// );
-// expect(graph_operator2.attr('rect')).toEqual(
-// { fill: '#FFFFFF', 'follow-scale': true, stroke: 'red', 'stroke-width': '2',
-// rx: '5px', ry: '5px' }
-// );
-// expect(graph_operator1.attr('.delete-button')).toEqual(
-// {
-// x: 60, y: -20, cursor: 'pointer',
-// fill: '#D8656A', event: 'element:delete'
-// }
-// );
-// expect(graph_operator2.attr('.delete-button')).toEqual(
-// {
-// x: 60, y: -20, cursor: 'pointer',
-// fill: '#D8656A', event: 'element:delete'
-// }
-// );
-
-// // testing getDefaultLinkElement()
-// expect(graph_link.attr('.marker-source/d')).toEqual(sourceOperatorHandle);
-// expect(graph_link.attr('.marker-target/d')).toEqual(targetOperatorHandle);
-// expect(graph_link.attr('.tool-remove path/d')).toEqual(deleteButtonPath);
-// });
-// });
-
import { of } from "rxjs";
import * as joint from "jointjs";
-import { JointUIService, operatorNameClass } from "./joint-ui.service";
-import { OperatorPredicate } from "../../types/workflow-common.interface";
+import { JointUIService, operatorNameClass, operatorStateClass, operatorPortMetricsClass } from "./joint-ui.service";
+import { CommentBox, OperatorPredicate } from "../../types/workflow-common.interface";
+import { OperatorState } from "../../types/execute-workflow.interface";
+import { Coeditor } from "../../../common/type/user";
+
+// Minimal mock of OperatorMetadataService — the constructor subscribes to
+// getOperatorMetadata() but the schemas list isn't needed for the methods
+// covered here. Tests that exercise `getJointOperatorElement` build their
+// own metadata stub with real schemas inline.
+const emptyMetadataStub = {
+ getOperatorMetadata: () =>
+ of({
+ operators: [],
+ groups: [],
+ }),
+};
describe("JointUIService", () => {
- // Pre-existing spec body is commented out. Placeholder keeps Vitest's
- // discovery happy; rewriting the real tests against the new test
- // runner is tracked in #4861.
- it.todo("add unit tests for JointUIService");
-
describe("truncateOperatorDisplayName", () => {
// Deterministic measurer: 10px per character. With the 200-px budget,
// 20 chars fit exactly; longer strings get truncated to a prefix plus "…".
@@ -511,4 +210,521 @@ describe("JointUIService", () => {
}
});
});
+
+ // ---------------------------------------------------------------------------
+ // Static helpers — pure functions, easiest to test directly.
+ // ---------------------------------------------------------------------------
+
+ describe("getOperatorFillColor (static)", () => {
+ it("returns the disabled fill for an isDisabled=true operator", () => {
+ expect(JointUIService.getOperatorFillColor({ isDisabled: true } as OperatorPredicate)).toBe("#E0E0E0");
+ });
+ it("returns the default white fill for an enabled operator", () => {
+ expect(JointUIService.getOperatorFillColor({} as OperatorPredicate)).toBe("#FFFFFF");
+ expect(JointUIService.getOperatorFillColor({ isDisabled: false } as OperatorPredicate)).toBe("#FFFFFF");
+ });
+ });
+
+ describe("getOperatorCacheDisplayText (static)", () => {
+ it("returns empty string when cacheStatus is undefined", () => {
+ expect(JointUIService.getOperatorCacheDisplayText({ markedForReuse: true } as OperatorPredicate)).toBe("");
+ });
+ it("returns empty string when the operator is not marked for reuse", () => {
+ expect(
+ JointUIService.getOperatorCacheDisplayText({ markedForReuse: false } as OperatorPredicate, "cache valid")
+ ).toBe("");
+ });
+ it("returns the cache status text when both are set", () => {
+ expect(
+ JointUIService.getOperatorCacheDisplayText({ markedForReuse: true } as OperatorPredicate, "cache valid")
+ ).toBe("cache valid");
+ });
+ });
+
+ describe("getOperatorCacheIcon (static)", () => {
+ it("returns empty when the operator is not marked for reuse", () => {
+ expect(JointUIService.getOperatorCacheIcon({ markedForReuse: false } as OperatorPredicate, "cache valid")).toBe(
+ ""
+ );
+ });
+ it("returns the valid-cache icon when cacheStatus is 'cache valid'", () => {
+ expect(JointUIService.getOperatorCacheIcon({ markedForReuse: true } as OperatorPredicate, "cache valid")).toBe(
+ "assets/svg/operator-reuse-cache-valid.svg"
+ );
+ });
+ it("returns the invalid-cache icon for any other status (including undefined)", () => {
+ expect(JointUIService.getOperatorCacheIcon({ markedForReuse: true } as OperatorPredicate)).toBe(
+ "assets/svg/operator-reuse-cache-invalid.svg"
+ );
+ expect(JointUIService.getOperatorCacheIcon({ markedForReuse: true } as OperatorPredicate, "cache invalid")).toBe(
+ "assets/svg/operator-reuse-cache-invalid.svg"
+ );
+ });
+ });
+
+ describe("getOperatorViewResultIcon (static)", () => {
+ it("returns the view-result asset when viewResult=true", () => {
+ expect(JointUIService.getOperatorViewResultIcon({ viewResult: true } as OperatorPredicate)).toBe(
+ "assets/svg/operator-view-result.svg"
+ );
+ });
+ it("returns empty otherwise", () => {
+ expect(JointUIService.getOperatorViewResultIcon({} as OperatorPredicate)).toBe("");
+ expect(JointUIService.getOperatorViewResultIcon({ viewResult: false } as OperatorPredicate)).toBe("");
+ });
+ });
+
+ describe("getJointLinkCell (static)", () => {
+ it("builds a joint link cell carrying source/target/id from the OperatorLink", () => {
+ const link = JointUIService.getJointLinkCell({
+ linkID: "link-1",
+ source: { operatorID: "op-A", portID: "out-0" },
+ target: { operatorID: "op-B", portID: "in-0" },
+ });
+ expect(link.id).toBe("link-1");
+ expect(link.get("source")).toEqual({ id: "op-A", port: "out-0" });
+ expect(link.get("target")).toEqual({ id: "op-B", port: "in-0" });
+ // z=0 keeps links rendered under operator elements (z=1 in
+ // getJointOperatorElement).
+ expect(link.get("z")).toBe(0);
+ });
+ });
+
+ describe("getJointUserPointerName (static)", () => {
+ it("prefixes the coeditor clientId with 'pointer_'", () => {
+ expect(JointUIService.getJointUserPointerName({ clientId: "abc123" } as Coeditor)).toBe("pointer_abc123");
+ });
+ });
+
+ describe("getJointUserPointerCell (static)", () => {
+ it("builds a circle cell whose id matches getJointUserPointerName", () => {
+ const coeditor = { clientId: "42", name: "Ada", color: "#ff0000" } as Coeditor;
+ const cell = JointUIService.getJointUserPointerCell(coeditor, { x: 10, y: 20 }, "#abcdef");
+ expect(cell.id).toBe(JointUIService.getJointUserPointerName(coeditor));
+ // attr('body/fill') reflects the explicit color argument.
+ expect(cell.attr("body/fill")).toBe("#abcdef");
+ expect(cell.attr("body/stroke")).toBe("#abcdef");
+ });
+ });
+
+ // ---------------------------------------------------------------------------
+ // Instance methods that operate on a joint Paper. Each test stubs the paper's
+ // getModelById to return a model with an `attr` spy; assertions look at what
+ // the SUT wrote on that model.
+ // ---------------------------------------------------------------------------
+
+ function makePaperWithModel() {
+ const attrSpy = vi.fn();
+ const portPropSpy = vi.fn();
+ const getPortsSpy = vi.fn(() => [] as { id?: string; group?: string }[]);
+ const model = { attr: attrSpy, getPorts: getPortsSpy, portProp: portPropSpy };
+ const paper = { getModelById: vi.fn(() => model) } as unknown as joint.dia.Paper;
+ return { paper, attrSpy, portPropSpy, getPortsSpy, model };
+ }
+
+ describe("changeOperatorColor", () => {
+ it("paints the body stroke neutral for a valid operator", () => {
+ const { paper, attrSpy } = makePaperWithModel();
+ const service = new JointUIService(emptyMetadataStub as never);
+ service.changeOperatorColor(paper, "op-1", true);
+ expect(attrSpy).toHaveBeenCalledWith("rect.body/stroke", "#CFCFCF");
+ });
+ it("paints the body stroke red for an invalid operator", () => {
+ const { paper, attrSpy } = makePaperWithModel();
+ const service = new JointUIService(emptyMetadataStub as never);
+ service.changeOperatorColor(paper, "op-1", false);
+ expect(attrSpy).toHaveBeenCalledWith("rect.body/stroke", "red");
+ });
+ });
+
+ describe("changeOperatorState", () => {
+ // For each state, the SUT writes a fill color to .${operatorStateClass};
+ // we only assert on the color since the rest of the attr payload (port
+ // labels, worker count) is exercised through the existing port mocks.
+ const cases: Array<[OperatorState, string]> = [
+ [OperatorState.Ready, "#a6bd37"],
+ [OperatorState.Completed, "green"],
+ [OperatorState.Paused, "magenta"],
+ [OperatorState.Pausing, "magenta"],
+ [OperatorState.Running, "orange"],
+ [OperatorState.Uninitialized, "gray"],
+ ];
+ cases.forEach(([state, color]) => {
+ it(`writes fill='${color}' for state=${state}`, () => {
+ const { paper, attrSpy } = makePaperWithModel();
+ const service = new JointUIService(emptyMetadataStub as never);
+ service.changeOperatorState(paper, "op-1", state);
+ // The attr payload is an object keyed by selectors; pluck the state class entry.
+ const [payload] = attrSpy.mock.calls[0];
+ expect(payload[`.${operatorStateClass}`]).toEqual({ text: state.toString(), fill: color });
+ expect(payload["rect.body"]).toEqual({ stroke: color });
+ });
+ });
+ });
+
+ describe("foldOperatorDetails / unfoldOperatorDetails", () => {
+ it("hides operator state + metric texts and the action buttons when folded", () => {
+ const { paper, attrSpy } = makePaperWithModel();
+ const service = new JointUIService(emptyMetadataStub as never);
+ service.foldOperatorDetails(paper, "op-1");
+ const [payload] = attrSpy.mock.calls[0];
+ expect(payload[`.${operatorStateClass}`].visibility).toBe("hidden");
+ expect(payload[`.${operatorPortMetricsClass}`].visibility).toBe("hidden");
+ expect(payload[".delete-button"].visibility).toBe("hidden");
+ expect(payload[".chat-button"].visibility).toBe("hidden");
+ });
+ it("reveals the same surface when unfolded", () => {
+ const { paper, attrSpy } = makePaperWithModel();
+ const service = new JointUIService(emptyMetadataStub as never);
+ service.unfoldOperatorDetails(paper, "op-1");
+ const [payload] = attrSpy.mock.calls[0];
+ expect(payload[`.${operatorStateClass}`].visibility).toBe("visible");
+ expect(payload[`.${operatorPortMetricsClass}`].visibility).toBe("visible");
+ expect(payload[".delete-button"].visibility).toBe("visible");
+ expect(payload[".chat-button"].visibility).toBe("visible");
+ });
+ });
+
+ describe("showAgentActionLabel / hideAgentActionLabel", () => {
+ it("writes the action label with the agent name prefix when the model exists", () => {
+ const { paper, attrSpy } = makePaperWithModel();
+ const service = new JointUIService(emptyMetadataStub as never);
+ service.showAgentActionLabel(paper, "op-1", "modified", "Aria");
+ const [payload] = attrSpy.mock.calls[0];
+ const entry = Object.values(payload)[0] as { text: string; visibility: string };
+ expect(entry.text).toBe("Aria: modified");
+ expect(entry.visibility).toBe("visible");
+ });
+ it("uses the default 'Agent' name when none is supplied", () => {
+ const { paper, attrSpy } = makePaperWithModel();
+ const service = new JointUIService(emptyMetadataStub as never);
+ service.showAgentActionLabel(paper, "op-1", "viewed");
+ const [payload] = attrSpy.mock.calls[0];
+ const entry = Object.values(payload)[0] as { text: string };
+ expect(entry.text).toBe("Agent: viewed");
+ });
+ it("no-ops when the model is missing", () => {
+ const paper = { getModelById: vi.fn(() => null) } as unknown as joint.dia.Paper;
+ const service = new JointUIService(emptyMetadataStub as never);
+ expect(() => service.showAgentActionLabel(paper, "missing-op", "added")).not.toThrow();
+ });
+ it("clears the label text and hides it on hideAgentActionLabel", () => {
+ const { paper, attrSpy } = makePaperWithModel();
+ const service = new JointUIService(emptyMetadataStub as never);
+ service.hideAgentActionLabel(paper, "op-1");
+ const [payload] = attrSpy.mock.calls[0];
+ const entry = Object.values(payload)[0] as { text: string; visibility: string };
+ expect(entry.text).toBe("");
+ expect(entry.visibility).toBe("hidden");
+ });
+ it("hideAgentActionLabel is a no-op when the model is missing", () => {
+ const paper = { getModelById: vi.fn(() => null) } as unknown as joint.dia.Paper;
+ const service = new JointUIService(emptyMetadataStub as never);
+ expect(() => service.hideAgentActionLabel(paper, "missing-op")).not.toThrow();
+ });
+ });
+
+ describe("getCommentElement", () => {
+ it("builds a comment element with the supplied commentBoxID and position", () => {
+ const service = new JointUIService(emptyMetadataStub as never);
+ const cell = service.getCommentElement({
+ commentBoxID: "cb-1",
+ commentBoxPosition: { x: 42, y: 99 },
+ comments: [],
+ } as unknown as CommentBox);
+ expect(cell.id).toBe("cb-1");
+ });
+ it("falls back to (0,0) when commentBoxPosition is missing", () => {
+ const service = new JointUIService(emptyMetadataStub as never);
+ // Should not throw — the implementation guards both the basic
+ // shape position and the joint element construction.
+ const cell = service.getCommentElement({
+ commentBoxID: "cb-no-pos",
+ comments: [],
+ } as unknown as CommentBox);
+ expect(cell.id).toBe("cb-no-pos");
+ });
+ });
+
+ describe("getJointOperatorElement", () => {
+ function buildMetadataWithSchemas(schemas: object[]) {
+ return {
+ getOperatorMetadata: () =>
+ of({
+ operators: schemas,
+ groups: [],
+ }),
+ };
+ }
+
+ const minimalSchema = (operatorType: string, friendlyName = "Friendly") => ({
+ operatorType,
+ operatorVersion: "v1",
+ jsonSchema: {},
+ additionalMetadata: { userFriendlyName: friendlyName },
+ });
+
+ it("throws when the operator type isn't in the loaded schema list", () => {
+ const service = new JointUIService(emptyMetadataStub as never);
+ const operator = {
+ operatorID: "op-x",
+ operatorType: "DefinitelyNotARealType",
+ operatorProperties: {},
+ inputPorts: [],
+ outputPorts: [],
+ showAdvanced: false,
+ } as unknown as OperatorPredicate;
+ expect(() => service.getJointOperatorElement(operator, { x: 0, y: 0 })).toThrow(
+ /operator type DefinitelyNotARealType doesn't exist/
+ );
+ });
+
+ it("returns an element carrying the predicate's operatorID and z-index 1", () => {
+ const schema = minimalSchema("TestOp", "Test Operator");
+ const service = new JointUIService(buildMetadataWithSchemas([schema]) as never);
+ const predicate = {
+ operatorID: "my-op",
+ operatorType: "TestOp",
+ operatorVersion: "v1",
+ operatorProperties: {},
+ inputPorts: [{ portID: "in-0" }],
+ outputPorts: [{ portID: "out-0" }],
+ showAdvanced: false,
+ isDisabled: false,
+ } as OperatorPredicate;
+ const element = service.getJointOperatorElement(predicate, { x: 100, y: 50 });
+ expect(element.id).toBe("my-op");
+ expect(element.get("z")).toBe(1);
+ // Both ports flow through addPort.
+ const ports = element.getPorts();
+ expect(ports.map(p => p.id).sort()).toEqual(["in-0", "out-0"]);
+ });
+
+ it("emits add/remove port buttons in the markup when dynamicInputPorts and dynamicOutputPorts are true", () => {
+ const schema = minimalSchema("DynamicOp");
+ const service = new JointUIService(buildMetadataWithSchemas([schema]) as never);
+ const predicate = {
+ operatorID: "dyn",
+ operatorType: "DynamicOp",
+ operatorVersion: "v1",
+ operatorProperties: {},
+ inputPorts: [],
+ outputPorts: [],
+ showAdvanced: false,
+ isDisabled: false,
+ dynamicInputPorts: true,
+ dynamicOutputPorts: true,
+ } as unknown as OperatorPredicate;
+ const element = service.getJointOperatorElement(predicate, { x: 0, y: 0 });
+ // The markup is stamped onto the element's attributes; both port-button
+ // classes only appear when their dynamic-ports flag is true.
+ const markup = (element as unknown as { attributes: { markup: string } }).attributes.markup;
+ expect(markup).toContain("add-input-port-button");
+ expect(markup).toContain("add-output-port-button");
+ expect(markup).toContain("remove-input-port-button");
+ expect(markup).toContain("remove-output-port-button");
+ });
+
+ it("renders customDisplayName in the operator name attr when supplied", () => {
+ const schema = minimalSchema("Named", "Friendly");
+ const service = new JointUIService(buildMetadataWithSchemas([schema]) as never);
+ const predicate = {
+ operatorID: "named",
+ operatorType: "Named",
+ operatorVersion: "v1",
+ operatorProperties: {},
+ inputPorts: [],
+ outputPorts: [],
+ showAdvanced: false,
+ isDisabled: false,
+ customDisplayName: "Custom-Display",
+ } as unknown as OperatorPredicate;
+ const element = service.getJointOperatorElement(predicate, { x: 0, y: 0 });
+ // The display name lands in `.texera-operator-name/text` via
+ // getCustomOperatorStyleAttrs. truncateOperatorDisplayName is the no-op
+ // identity for short names so the value comes through verbatim.
+ expect(element.attr(`.${operatorNameClass}/text`)).toBe("Custom-Display");
+ });
+ });
+
+ // ---------------------------------------------------------------------------
+ // Port-iteration paths — the original tests stubbed getPorts() to return [],
+ // which skipped the forEach branches inside changeOperatorState and the
+ // entire body of changeOperatorStatistics. The tests below cover those.
+ // ---------------------------------------------------------------------------
+
+ describe("changeOperatorState — port label re-coloring", () => {
+ it("re-paints every input and output port label to the state's fill color", () => {
+ const attrSpy = vi.fn();
+ const portPropSpy = vi.fn();
+ // changeOperatorState iterates getPorts() splitting by group; both
+ // groups must be present so the in/out filters each yield matches.
+ const getPortsSpy = vi.fn(() => [
+ { id: "in-0", group: "in" },
+ { id: "out-0", group: "out" },
+ ]);
+ const model = { attr: attrSpy, getPorts: getPortsSpy, portProp: portPropSpy };
+ const paper = { getModelById: vi.fn(() => model) } as unknown as joint.dia.Paper;
+ const service = new JointUIService(emptyMetadataStub as never);
+
+ service.changeOperatorState(paper, "op-1", OperatorState.Running);
+
+ // Running → orange. Both ports get the same color through portProp.
+ expect(portPropSpy).toHaveBeenCalledWith("in-0", "attrs/.port-label/fill", "orange");
+ expect(portPropSpy).toHaveBeenCalledWith("out-0", "attrs/.port-label/fill", "orange");
+ });
+ });
+
+ describe("changeOperatorDisableStatus", () => {
+ it("paints the body fill with the disabled grey color for a disabled operator", () => {
+ const { paper, attrSpy } = makePaperWithModel();
+ const service = new JointUIService(emptyMetadataStub as never);
+ service.changeOperatorDisableStatus(paper, { operatorID: "op-1", isDisabled: true } as OperatorPredicate);
+ expect(attrSpy).toHaveBeenCalledWith("rect.body/fill", "#E0E0E0");
+ });
+ it("paints the body fill with the default white color for an enabled operator", () => {
+ const { paper, attrSpy } = makePaperWithModel();
+ const service = new JointUIService(emptyMetadataStub as never);
+ service.changeOperatorDisableStatus(paper, { operatorID: "op-1" } as OperatorPredicate);
+ expect(attrSpy).toHaveBeenCalledWith("rect.body/fill", "#FFFFFF");
+ });
+ });
+
+ describe("changeOperatorViewResultStatus", () => {
+ it("writes the view-result asset path when viewResult is true", () => {
+ const { paper, attrSpy } = makePaperWithModel();
+ const service = new JointUIService(emptyMetadataStub as never);
+ service.changeOperatorViewResultStatus(paper, {
+ operatorID: "op-1",
+ viewResult: true,
+ } as unknown as OperatorPredicate);
+ expect(attrSpy).toHaveBeenCalledTimes(1);
+ const [selector, value] = attrSpy.mock.calls[0];
+ expect(String(selector)).toContain("view-result");
+ expect(value).toBe("assets/svg/operator-view-result.svg");
+ });
+ it("writes the empty asset path when viewResult is missing/false", () => {
+ const { paper, attrSpy } = makePaperWithModel();
+ const service = new JointUIService(emptyMetadataStub as never);
+ service.changeOperatorViewResultStatus(paper, { operatorID: "op-1" } as OperatorPredicate);
+ expect(attrSpy.mock.calls[0][1]).toBe("");
+ });
+ });
+
+ describe("changeOperatorReuseCacheStatus", () => {
+ it("writes both the reuse-cache icon and the view-result icon when the cache is valid", () => {
+ const { paper, attrSpy } = makePaperWithModel();
+ const service = new JointUIService(emptyMetadataStub as never);
+ service.changeOperatorReuseCacheStatus(
+ paper,
+ { operatorID: "op-1", markedForReuse: true } as unknown as OperatorPredicate,
+ "cache valid"
+ );
+ // Two attr() calls — one for the cache icon, one for the view-result
+ // icon — both targeted at the operator's image attrs.
+ expect(attrSpy).toHaveBeenCalledTimes(2);
+ const allValues = attrSpy.mock.calls.map(c => c[1]).join(" | ");
+ expect(allValues).toContain("assets/svg/operator-reuse-cache-valid.svg");
+ });
+ it("writes the empty path for both icons when the operator isn't marked for reuse", () => {
+ const { paper, attrSpy } = makePaperWithModel();
+ const service = new JointUIService(emptyMetadataStub as never);
+ service.changeOperatorReuseCacheStatus(paper, {
+ operatorID: "op-1",
+ markedForReuse: false,
+ } as unknown as OperatorPredicate);
+ expect(attrSpy).toHaveBeenCalledTimes(2);
+ expect(attrSpy.mock.calls.every(c => c[1] === "")).toBe(true);
+ });
+ });
+
+ describe("changeOperatorStatistics", () => {
+ function makeStatsPaper(getPortsImpl: () => Array<{ id?: string; group?: string; attrs?: unknown }>) {
+ const attrSpy = vi.fn();
+ const portPropSpy = vi.fn();
+ const getPortsSpy = vi.fn(getPortsImpl);
+ const model = { attr: attrSpy, getPorts: getPortsSpy, portProp: portPropSpy };
+ const paper = { getModelById: vi.fn(() => model) } as unknown as joint.dia.Paper;
+ return { paper, attrSpy, portPropSpy };
+ }
+
+ it("falls back to the Uninitialized state when statistics is undefined", () => {
+ const { paper, attrSpy } = makeStatsPaper(() => []);
+ const service = new JointUIService(emptyMetadataStub as never);
+ service.changeOperatorStatistics(paper, "op-1", undefined, false, false);
+ // changeOperatorState writes the state-class fill payload.
+ const [payload] = attrSpy.mock.calls[0];
+ expect((payload as Record<string, { text: string }>)[`.${operatorStateClass}`].text).toBe(
+ OperatorState.Uninitialized.toString()
+ );
+ });
+
+ it("writes per-port counts derived from inputPortMetrics and outputPortMetrics", () => {
+ const { paper, portPropSpy } = makeStatsPaper(() => [
+ // Port IDs use the "<group>-<index>" convention; the SUT splits on "-"
+ // and uses the suffix to look up the metrics map.
+ { id: "in-0", group: "in", attrs: { ".port-label": { text: "data: 0" } } },
+ { id: "out-1", group: "out", attrs: { ".port-label": { text: "result: 0" } } },
+ ]);
+ const service = new JointUIService(emptyMetadataStub as never);
+ service.changeOperatorStatistics(
+ paper,
+ "op-1",
+ {
+ operatorState: OperatorState.Running,
+ aggregatedInputRowCount: 0,
+ aggregatedOutputRowCount: 0,
+ inputPortMetrics: { "0": 42 },
+ outputPortMetrics: { "1": 7 },
+ numWorkers: 3,
+ },
+ false,
+ false
+ );
+ expect(portPropSpy).toHaveBeenCalledWith("in-0", "attrs/.port-label/text", (42).toLocaleString());
+ expect(portPropSpy).toHaveBeenCalledWith("out-1", "attrs/.port-label/text", (7).toLocaleString());
+ });
+
+ it("writes the worker count label when statistics include numWorkers", () => {
+ const { paper, attrSpy } = makeStatsPaper(() => []);
+ const service = new JointUIService(emptyMetadataStub as never);
+ service.changeOperatorStatistics(
+ paper,
+ "op-1",
+ {
+ operatorState: OperatorState.Ready,
+ aggregatedInputRowCount: 0,
+ aggregatedOutputRowCount: 0,
+ inputPortMetrics: {},
+ outputPortMetrics: {},
+ numWorkers: 8,
+ },
+ false,
+ false
+ );
+ // attr() is called once with the workers selector and the formatted string.
+ const valuesWritten = attrSpy.mock.calls.map(c => c[1]);
+ expect(valuesWritten).toContain("#workers: 8");
+ });
+
+ it("defaults the worker count to 1 when numWorkers is unspecified", () => {
+ const { paper, attrSpy } = makeStatsPaper(() => []);
+ const service = new JointUIService(emptyMetadataStub as never);
+ service.changeOperatorStatistics(
+ paper,
+ "op-1",
+ {
+ operatorState: OperatorState.Ready,
+ aggregatedInputRowCount: 0,
+ aggregatedOutputRowCount: 0,
+ inputPortMetrics: {},
+ outputPortMetrics: {},
+ },
+ false,
+ false
+ );
+ const valuesWritten = attrSpy.mock.calls.map(c => c[1]);
+ expect(valuesWritten).toContain("#workers: 1");
+ });
+ });
});
diff --git a/frontend/src/app/workspace/service/joint-ui/joint-ui.service.ts b/frontend/src/app/workspace/service/joint-ui/joint-ui.service.ts
index d5d8f78c58..d069a270eb 100644
--- a/frontend/src/app/workspace/service/joint-ui/joint-ui.service.ts
+++ b/frontend/src/app/workspace/service/joint-ui/joint-ui.service.ts
@@ -396,18 +396,8 @@ export class JointUIService {
if (portId != null) {
const parts = portId.split("-");
const numericSuffix = parts.length > 1 ? parts[1] : portId;
-
const count: number = inputMetrics[numericSuffix] ?? 0;
- const rawAttrs = (portDef.attrs as any) || {};
- const oldText: string = (rawAttrs[".port-label"] && rawAttrs[".port-label"].text) || "";
- let originalName = oldText.includes(":") ? oldText.split(":", 1)[0].trim() : oldText;
-
- if (!originalName) {
- originalName = portId;
- }
-
- const labelText = count.toLocaleString();
- element.portProp(portId, "attrs/.port-label/text", labelText);
+ element.portProp(portId, "attrs/.port-label/text", count.toLocaleString());
}
});
@@ -416,19 +406,8 @@ export class JointUIService {
if (portId != null) {
const parts = portId.split("-");
const numericSuffix = parts.length > 1 ? parts[1] : portId;
-
const count: number = outputMetrics[numericSuffix] ?? 0;
- const rawAttrs = (portDef.attrs as any) || {};
- const oldText: string = (rawAttrs[".port-label"] && rawAttrs[".port-label"].text) || "";
- let originalName = oldText.includes(":") ? oldText.split(":", 1)[0].trim() : oldText;
-
- if (!originalName) {
- originalName = portId;
- }
-
- const labelText = count.toLocaleString();
-
- element.portProp(portId, "attrs/.port-label/text", labelText);
+ element.portProp(portId, "attrs/.port-label/text", count.toLocaleString());
}
});
this.changeOperatorState(jointPaper, operatorID, statistics.operatorState);
@@ -457,11 +436,6 @@ export class JointUIService {
".remove-input-port-button": { visibility: "visible" },
".remove-output-port-button": { visibility: "visible" },
});
-
- const element = jointPaper.getModelById(operatorID) as joint.shapes.devs.Model;
- if (!element) {
- return;
- }
}
public changeOperatorState(jointPaper: joint.dia.Paper, operatorID: string, operatorState: OperatorState): void {
@@ -696,40 +670,6 @@ export class JointUIService {
};
}
- /**
- * This function create a custom svg style for the operator
- * @returns the custom attributes of the tooltip.
- */
- public static getCustomOperatorStatusTooltipStyleAttrs(): joint.shapes.devs.ModelSelectors {
- return {
- "element-node": {
- style: { "pointer-events": "none" },
- },
- polygon: {
- fill: "#FFFFFF",
- "follow-scale": true,
- stroke: "purple",
- "stroke-width": "2",
- rx: "5px",
- ry: "5px",
- refPoints: "0,30 150,30 150,120 85,120 75,150 65,120 0,120",
- display: "none",
- style: { "pointer-events": "none" },
- },
- "#operatorCount": {
- fill: "#595959",
- "font-size": "12px",
- ref: "polygon",
- "y-alignment": "middle",
- "x-alignment": "left",
- "ref-x": 0.05,
- "ref-y": 0.2,
- display: "none",
- style: { "pointer-events": "none" },
- },
- };
- }
-
/**
* This function creates a custom svg style for the operator.
* This function also makes the delete button defined above to emit the delete event that will