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/07/20 06:05:56 UTC

(texera) 01/01: test(frontend): add unit test coverage for WorkflowResultExportService, WorkflowExecutionsService, and areOperatorSchemasEqual (#6570)

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-6570-028cbd6b9b57cc2e09d56dab1a1b8d6c1f32904b
in repository https://gitbox.apache.org/repos/asf/texera.git

commit 4b0bbc746056cde617086cca8186024f140b8b15
Author: Meng Wang <me...@uci.edu>
AuthorDate: Sun Jul 19 23:05:48 2026 -0700

    test(frontend): add unit test coverage for WorkflowResultExportService, WorkflowExecutionsService, and areOperatorSchemasEqual (#6570)
    
    ### What changes were proposed in this PR?
    
    Adds/extends Vitest coverage for three previously-thin logic-layer
    targets
    (28 tests total, all jsdom, no browser mode):
    
    - **`areOperatorSchemasEqual`**
    (`workspace/types/operator-schema.interface.ts`,
    was ~9%, no spec) — new pure-function spec (no TestBed). Covers
    identical
      schemas → `true`, and `false` for differences in `operatorType` /
    `operatorVersion` / nested `jsonSchema` fields / `jsonSchema` array
    order /
    each `additionalMetadata` field / input & output port count,
    `displayName`,
      `disallowMultiLinks`, and port array order.
    
    - **`WorkflowResultExportService`**
    (`workspace/service/workflow-result-export/`,
      was ~42%, creation-only spec) — extended with `resetFlags` (resets the
    export-availability flags to defaults), plus a new suite for the
    exported
    `WorkflowResultDownloadability` helper class: `getExportableOperatorIds`
    /
    `getBlockedOperatorIds` partition operators against the restricted map,
    and
    `getBlockingDatasets` returns the deduped union of blocking labels. The
    HTTP/side-effecting `performExport` / `exportWorkflowExecutionResult`
    paths
      are intentionally out of scope.
    
    - **`WorkflowExecutionsService`**
    (`dashboard/service/user/workflow-executions/`,
    was ~33%, `it.todo` stub) — replaced the stub with
    `HttpClientTestingModule` +
    `HttpTestingController` tests asserting method / URL / body / query
    params for
    `retrieveLatestWorkflowExecution`, `retrieveWorkflowExecutions` (with
    and
    without a status filter), `groupSetIsBookmarked`,
    `groupDeleteWorkflowExecutions`,
    `updateWorkflowExecutionsName`, and `retrieveWorkflowRuntimeStatistics`.
    
    All tests are deterministic (pure functions, synchronous
    `of(...)`/flushed HTTP,
    `httpMock.verify()` in `afterEach`). No production code was changed.
    
    ### Any related issues, documentation, discussions?
    
    Closes #6567
    
    ### How was this PR tested?
    
    New/extended unit tests, run locally in `frontend/` (all green; each
    file's
    failure path was verified by breaking an assertion to confirm the suite
    goes red):
    
    ```
    ng test --watch=false --include src/app/workspace/types/operator-schema.interface.spec.ts               # 14 passed
    ng test --watch=false --include src/app/workspace/service/workflow-result-export/workflow-result-export.service.spec.ts  # 7 passed
    ng test --watch=false --include src/app/dashboard/service/user/workflow-executions/workflow-executions.service.spec.ts   # 7 passed
    eslint <specs>      # clean
    prettier --check    # clean
    ```
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Opus 4.8 [1M context])
---
 .../workflow-executions.service.spec.ts            |  94 ++++++++++++++-
 .../workflow-result-export.service.spec.ts         |  50 +++++++-
 .../types/operator-schema.interface.spec.ts        | 133 +++++++++++++++++++++
 3 files changed, 272 insertions(+), 5 deletions(-)

diff --git a/frontend/src/app/dashboard/service/user/workflow-executions/workflow-executions.service.spec.ts b/frontend/src/app/dashboard/service/user/workflow-executions/workflow-executions.service.spec.ts
index 69bc54c80d..66d89ece5c 100644
--- a/frontend/src/app/dashboard/service/user/workflow-executions/workflow-executions.service.spec.ts
+++ b/frontend/src/app/dashboard/service/user/workflow-executions/workflow-executions.service.spec.ts
@@ -17,9 +17,95 @@
  * under the License.
  */
 
+import { TestBed } from "@angular/core/testing";
+import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing";
+import { WORKFLOW_EXECUTIONS_API_BASE_URL, WorkflowExecutionsService } from "./workflow-executions.service";
+import { ExecutionState } from "../../../../workspace/types/execute-workflow.interface";
+import { WorkflowExecutionsEntry } from "../../../type/workflow-executions-entry";
+import { WorkflowRuntimeStatistics } from "../../../type/workflow-runtime-statistics";
+
 describe("WorkflowExecutionsService", () => {
-  // This spec was created without test bodies. The placeholder below keeps
-  // Vitest's discovery happy so the file compiles cleanly; real tests for
-  // WorkflowExecutionsService are tracked in #4861.
-  it.todo("add unit tests for WorkflowExecutionsService");
+  let service: WorkflowExecutionsService;
+  let httpMock: HttpTestingController;
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({
+      imports: [HttpClientTestingModule],
+      providers: [WorkflowExecutionsService],
+    });
+    service = TestBed.inject(WorkflowExecutionsService);
+    httpMock = TestBed.inject(HttpTestingController);
+  });
+
+  afterEach(() => {
+    httpMock.verify();
+  });
+
+  it("retrieveLatestWorkflowExecution issues GET to the latest url", () => {
+    let result: WorkflowExecutionsEntry | undefined;
+    service.retrieveLatestWorkflowExecution(1).subscribe(res => (result = res));
+
+    const req = httpMock.expectOne(`${WORKFLOW_EXECUTIONS_API_BASE_URL}/1/latest`);
+    expect(req.request.method).toBe("GET");
+    const entry = { eId: 7 } as unknown as WorkflowExecutionsEntry;
+    req.flush(entry);
+    expect(result).toEqual(entry);
+  });
+
+  it("retrieveWorkflowExecutions issues GET without a status param when no statuses are given", () => {
+    service.retrieveWorkflowExecutions(1).subscribe();
+
+    const req = httpMock.expectOne(r => r.url === `${WORKFLOW_EXECUTIONS_API_BASE_URL}/1`);
+    expect(req.request.method).toBe("GET");
+    expect(req.request.params.has("status")).toBe(false);
+    req.flush([]);
+  });
+
+  it("retrieveWorkflowExecutions sets the status param when statuses are given", () => {
+    service.retrieveWorkflowExecutions(1, [ExecutionState.Running, ExecutionState.Completed]).subscribe();
+
+    const req = httpMock.expectOne(r => r.url === `${WORKFLOW_EXECUTIONS_API_BASE_URL}/1`);
+    expect(req.request.method).toBe("GET");
+    expect(req.request.params.get("status")).toBe([ExecutionState.Running, ExecutionState.Completed].join(","));
+    req.flush([]);
+  });
+
+  it("groupSetIsBookmarked issues PUT with the bookmark payload", () => {
+    service.groupSetIsBookmarked(1, [10, 11], true).subscribe();
+
+    const req = httpMock.expectOne(`${WORKFLOW_EXECUTIONS_API_BASE_URL}/set_execution_bookmarks`);
+    expect(req.request.method).toBe("PUT");
+    expect(req.request.body).toEqual({ wid: 1, eIds: [10, 11], isBookmarked: true });
+    req.flush({});
+  });
+
+  it("groupDeleteWorkflowExecutions issues PUT with the delete payload", () => {
+    service.groupDeleteWorkflowExecutions(1, [10, 11]).subscribe();
+
+    const req = httpMock.expectOne(`${WORKFLOW_EXECUTIONS_API_BASE_URL}/delete_executions`);
+    expect(req.request.method).toBe("PUT");
+    expect(req.request.body).toEqual({ wid: 1, eIds: [10, 11] });
+    req.flush({});
+  });
+
+  it("updateWorkflowExecutionsName issues POST with the rename payload", () => {
+    service.updateWorkflowExecutionsName(1, 10, "renamed").subscribe();
+
+    const req = httpMock.expectOne(`${WORKFLOW_EXECUTIONS_API_BASE_URL}/update_execution_name`);
+    expect(req.request.method).toBe("POST");
+    expect(req.request.body).toEqual({ wid: 1, eId: 10, executionName: "renamed" });
+    req.flush({});
+  });
+
+  it("retrieveWorkflowRuntimeStatistics issues GET with the cuid param", () => {
+    let result: WorkflowRuntimeStatistics[] | undefined;
+    service.retrieveWorkflowRuntimeStatistics(1, 10, 5).subscribe(res => (result = res));
+
+    const req = httpMock.expectOne(r => r.url === `${WORKFLOW_EXECUTIONS_API_BASE_URL}/1/stats/10`);
+    expect(req.request.method).toBe("GET");
+    expect(req.request.params.get("cuid")).toBe("5");
+    const stats: WorkflowRuntimeStatistics[] = [];
+    req.flush(stats);
+    expect(result).toEqual(stats);
+  });
 });
diff --git a/frontend/src/app/workspace/service/workflow-result-export/workflow-result-export.service.spec.ts b/frontend/src/app/workspace/service/workflow-result-export/workflow-result-export.service.spec.ts
index 01900ccffb..e53d8ca0ab 100644
--- a/frontend/src/app/workspace/service/workflow-result-export/workflow-result-export.service.spec.ts
+++ b/frontend/src/app/workspace/service/workflow-result-export/workflow-result-export.service.spec.ts
@@ -18,7 +18,7 @@
  */
 
 import { TestBed } from "@angular/core/testing";
-import { WorkflowResultExportService } from "./workflow-result-export.service";
+import { WorkflowResultDownloadability, WorkflowResultExportService } from "./workflow-result-export.service";
 import { HttpClientTestingModule } from "@angular/common/http/testing";
 import { WorkflowWebsocketService } from "../workflow-websocket/workflow-websocket.service";
 import { WorkflowActionService } from "../workflow-graph/model/workflow-action.service";
@@ -126,4 +126,52 @@ describe("WorkflowResultExportService", () => {
   it("should be created", () => {
     expect(service).toBeTruthy();
   });
+
+  it("resetFlags clears the highlighted flag and resets the all-operators subject to false", () => {
+    service.hasResultToExportOnHighlightedOperators = true;
+    service.hasResultToExportOnAllOperators.next(true);
+
+    service.resetFlags();
+
+    expect(service.hasResultToExportOnHighlightedOperators).toBe(false);
+    expect(service.hasResultToExportOnAllOperators.value).toBe(false);
+  });
+});
+
+describe("WorkflowResultDownloadability", () => {
+  const restrictedMap = new Map<string, Set<string>>([
+    ["opB", new Set(["ds1 (a@x.com)"])],
+    ["opD", new Set(["ds2 (b@y.com)", "ds1 (a@x.com)"])],
+  ]);
+  const ids = ["opA", "opB", "opC", "opD"];
+
+  it("getExportableOperatorIds keeps only operators absent from the restricted map", () => {
+    const downloadability = new WorkflowResultDownloadability(restrictedMap);
+    expect(downloadability.getExportableOperatorIds(ids)).toEqual(["opA", "opC"]);
+  });
+
+  it("getBlockedOperatorIds keeps only operators present in the restricted map", () => {
+    const downloadability = new WorkflowResultDownloadability(restrictedMap);
+    expect(downloadability.getBlockedOperatorIds(ids)).toEqual(["opB", "opD"]);
+  });
+
+  it("getBlockingDatasets returns the deduped union of blocking dataset labels", () => {
+    const downloadability = new WorkflowResultDownloadability(restrictedMap);
+    const result = downloadability.getBlockingDatasets(["opB", "opD"]);
+    // ds1 is shared by opB and opD, so the union must be deduped (length 2, not 3).
+    // The return builds a Set, so assert membership order-independently.
+    expect(result).toHaveLength(2);
+    expect([...result].sort()).toEqual(["ds1 (a@x.com)", "ds2 (b@y.com)"]);
+  });
+
+  it("getBlockingDatasets returns an empty array for unrestricted operators", () => {
+    const downloadability = new WorkflowResultDownloadability(restrictedMap);
+    expect(downloadability.getBlockingDatasets(["opA", "opC"])).toEqual([]);
+  });
+
+  it("treats every operator as exportable when the restricted map is empty", () => {
+    const downloadability = new WorkflowResultDownloadability(new Map());
+    expect(downloadability.getExportableOperatorIds(ids)).toEqual(ids);
+    expect(downloadability.getBlockedOperatorIds(ids)).toEqual([]);
+  });
 });
diff --git a/frontend/src/app/workspace/types/operator-schema.interface.spec.ts b/frontend/src/app/workspace/types/operator-schema.interface.spec.ts
new file mode 100644
index 0000000000..069f5bd78f
--- /dev/null
+++ b/frontend/src/app/workspace/types/operator-schema.interface.spec.ts
@@ -0,0 +1,133 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { OperatorSchema, areOperatorSchemasEqual } from "./operator-schema.interface";
+
+function schema(
+  overrides: {
+    operatorType?: string;
+    operatorVersion?: string;
+    jsonSchema?: object;
+    userFriendlyName?: string;
+    operatorGroupName?: string;
+    operatorDescription?: string;
+    supportReconfiguration?: boolean;
+    allowPortCustomization?: boolean;
+    inputPorts?: { displayName?: string; disallowMultiLinks?: boolean }[];
+    outputPorts?: { displayName?: string }[];
+  } = {}
+): OperatorSchema {
+  return {
+    operatorType: overrides.operatorType ?? "ScanSource",
+    operatorVersion: overrides.operatorVersion ?? "1.0",
+    jsonSchema: (overrides.jsonSchema ?? {
+      type: "object",
+      properties: { a: { type: "string" }, b: { type: "number" } },
+    }) as unknown as OperatorSchema["jsonSchema"],
+    additionalMetadata: {
+      userFriendlyName: overrides.userFriendlyName ?? "Scan",
+      operatorGroupName: overrides.operatorGroupName ?? "Source",
+      operatorDescription: overrides.operatorDescription ?? "reads data",
+      inputPorts: overrides.inputPorts ?? [{ displayName: "in0", disallowMultiLinks: false }],
+      outputPorts: overrides.outputPorts ?? [{ displayName: "out0" }],
+      supportReconfiguration: overrides.supportReconfiguration ?? false,
+      allowPortCustomization: overrides.allowPortCustomization ?? false,
+    },
+  };
+}
+
+describe("areOperatorSchemasEqual", () => {
+  it("returns true for two identically-shaped schemas", () => {
+    expect(areOperatorSchemasEqual(schema(), schema())).toBe(true);
+  });
+
+  it("returns false when operatorType differs", () => {
+    expect(areOperatorSchemasEqual(schema(), schema({ operatorType: "CSVFileScan" }))).toBe(false);
+  });
+
+  it("returns false when operatorVersion differs", () => {
+    expect(areOperatorSchemasEqual(schema(), schema({ operatorVersion: "2.0" }))).toBe(false);
+  });
+
+  it("returns false when a nested jsonSchema field differs", () => {
+    const other = schema({ jsonSchema: { type: "object", properties: { a: { type: "integer" } } } });
+    expect(areOperatorSchemasEqual(schema(), other)).toBe(false);
+  });
+
+  it("returns false when jsonSchema arrays differ only in order", () => {
+    const a = schema({ jsonSchema: { type: "object", required: ["a", "b"] } });
+    const b = schema({ jsonSchema: { type: "object", required: ["b", "a"] } });
+    expect(areOperatorSchemasEqual(a, b)).toBe(false);
+  });
+
+  it("returns false when additionalMetadata.userFriendlyName differs", () => {
+    expect(areOperatorSchemasEqual(schema(), schema({ userFriendlyName: "Scanner" }))).toBe(false);
+  });
+
+  it("returns false when additionalMetadata.operatorGroupName differs", () => {
+    expect(areOperatorSchemasEqual(schema(), schema({ operatorGroupName: "Analysis" }))).toBe(false);
+  });
+
+  it("returns false when a boolean metadata flag differs", () => {
+    expect(areOperatorSchemasEqual(schema(), schema({ supportReconfiguration: true }))).toBe(false);
+  });
+
+  it("returns false when additionalMetadata.operatorDescription differs", () => {
+    expect(areOperatorSchemasEqual(schema(), schema({ operatorDescription: "different" }))).toBe(false);
+  });
+
+  it("returns false when additionalMetadata.allowPortCustomization differs", () => {
+    expect(areOperatorSchemasEqual(schema(), schema({ allowPortCustomization: true }))).toBe(false);
+  });
+
+  it("returns false when the number of input ports differs", () => {
+    const other = schema({ inputPorts: [{ displayName: "in0" }, { displayName: "in1" }] });
+    expect(areOperatorSchemasEqual(schema(), other)).toBe(false);
+  });
+
+  it("returns false when an input port's displayName differs", () => {
+    expect(areOperatorSchemasEqual(schema(), schema({ inputPorts: [{ displayName: "renamed" }] }))).toBe(false);
+  });
+
+  it("returns false when an input port's disallowMultiLinks differs", () => {
+    const other = schema({ inputPorts: [{ displayName: "in0", disallowMultiLinks: true }] });
+    expect(areOperatorSchemasEqual(schema(), other)).toBe(false);
+  });
+
+  it("returns false when input ports match as a set but differ in order", () => {
+    const a = schema({ inputPorts: [{ displayName: "in0" }, { displayName: "in1" }] });
+    const b = schema({ inputPorts: [{ displayName: "in1" }, { displayName: "in0" }] });
+    expect(areOperatorSchemasEqual(a, b)).toBe(false);
+  });
+
+  it("returns false when the number of output ports differs", () => {
+    const other = schema({ outputPorts: [{ displayName: "out0" }, { displayName: "out1" }] });
+    expect(areOperatorSchemasEqual(schema(), other)).toBe(false);
+  });
+
+  it("returns false when an output port's displayName differs", () => {
+    expect(areOperatorSchemasEqual(schema(), schema({ outputPorts: [{ displayName: "renamed" }] }))).toBe(false);
+  });
+
+  it("returns false when output ports match as a set but differ in order", () => {
+    const a = schema({ outputPorts: [{ displayName: "out0" }, { displayName: "out1" }] });
+    const b = schema({ outputPorts: [{ displayName: "out1" }, { displayName: "out0" }] });
+    expect(areOperatorSchemasEqual(a, b)).toBe(false);
+  });
+});