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/12 23:54:55 UTC
(texera) branch main updated: test(frontend): extend WorkflowResultService coverage (#6369)
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 a9423e2c00 test(frontend): extend WorkflowResultService coverage (#6369)
a9423e2c00 is described below
commit a9423e2c0014c7fbc728b38a7b36b4cf972e3e99
Author: Xinyuan Lin <xi...@uci.edu>
AuthorDate: Sun Jul 12 16:43:34 2026 -0700
test(frontend): extend WorkflowResultService coverage (#6369)
### What changes were proposed in this PR?
Extends the `WorkflowResultService` spec (was ~29% covered):
result-update pagination/data + stale-service branches, table-stats
updates, cache clean/invalidate/reset + stream emission,
paginated/operator result init, output-type/extension determination, and
the OperatorResultService/OperatorPaginationResultService snapshot,
page-selection, and stats-rotation paths.
### Any related issues, documentation, discussions?
Closes #6366.
### How was this PR tested?
Added 21 tests (now 28), reusing the existing harness and driving the
real WorkflowWebsocketService's event subject to run the service's
subscriptions end-to-end without a live socket. Verified locally with
Vitest and CI's `format:ci` gate clean. Test-only change — no production
code is modified.
### Was this PR authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Opus 4.8 [1M context])
---
.../workflow-result.service.spec.ts | 337 ++++++++++++++++++++-
1 file changed, 336 insertions(+), 1 deletion(-)
diff --git a/frontend/src/app/workspace/service/workflow-result/workflow-result.service.spec.ts b/frontend/src/app/workspace/service/workflow-result/workflow-result.service.spec.ts
index 031de9bfa0..8ed9d41b4a 100644
--- a/frontend/src/app/workspace/service/workflow-result/workflow-result.service.spec.ts
+++ b/frontend/src/app/workspace/service/workflow-result/workflow-result.service.spec.ts
@@ -18,12 +18,35 @@
*/
import { TestBed } from "@angular/core/testing";
-import { OperatorPaginationResultService, WorkflowResultService } from "./workflow-result.service";
+import {
+ OperatorPaginationResultService,
+ OperatorResultService,
+ WorkflowResultService,
+} from "./workflow-result.service";
import { WorkflowWebsocketService } from "../workflow-websocket/workflow-websocket.service";
import { firstValueFrom, of, Subject } from "rxjs";
import { SchemaAttribute } from "../../types/workflow-compiling.interface";
+import { WebDataUpdate, WebPaginationUpdate } from "../../types/execute-workflow.interface";
+import { PaginatedResultEvent } from "../../types/workflow-websocket.interface";
import { commonTestProviders } from "../../../common/testing/test-utils";
import type { Mocked } from "vitest";
+
+/**
+ * Push a raw websocket event through the real WorkflowWebsocketService's response subject so the
+ * WorkflowResultService's constructor subscriptions fire exactly as they would against a live socket.
+ */
+function pushWsEvent(ws: WorkflowWebsocketService, event: Record<string, unknown>): void {
+ (ws as any).webSocketResponseSubject.next(event);
+}
+
+function paginationUpdate(totalNumTuples: number, dirtyPageIndices: number[] = []): WebPaginationUpdate {
+ return { mode: { type: "PaginationMode" }, totalNumTuples, dirtyPageIndices };
+}
+
+function snapshotUpdate(table: ReadonlyArray<object>): WebDataUpdate {
+ return { mode: { type: "SetSnapshotMode" }, table };
+}
+
describe("WorkflowResultService", () => {
let service: WorkflowResultService;
@@ -64,6 +87,182 @@ describe("WorkflowResultService", () => {
service.clearResults();
expect(clearedCount).toBe(1);
});
+
+ it("routes pagination updates to a paginated service and data updates to a result service", () => {
+ const ws = TestBed.inject(WorkflowWebsocketService);
+ const updateEvents: Record<string, unknown>[] = [];
+ service.getResultUpdateStream().subscribe(u => updateEvents.push(u));
+
+ const updates = { pagOp: paginationUpdate(42, [2]), dataOp: snapshotUpdate([{ a: 1 }]) };
+ pushWsEvent(ws, { type: "WebResultUpdateEvent", updates, tableStats: {} });
+
+ expect(service.hasPaginatedResult("pagOp")).toBe(true);
+ expect(service.hasResult("pagOp")).toBe(false);
+ expect(service.hasResult("dataOp")).toBe(true);
+ expect(service.hasPaginatedResult("dataOp")).toBe(false);
+ expect(service.hasAnyResult("pagOp")).toBe(true);
+ expect(service.getPaginatedResultService("pagOp")!.getCurrentTotalNumTuples()).toBe(42);
+ expect(service.getResultService("dataOp")!.getCurrentResultSnapshot()).toEqual([{ a: 1 }]);
+ // the raw update record is republished verbatim on the result-update stream
+ expect(updateEvents).toEqual([updates]);
+ });
+
+ it("announces newly-created operators on the result-initiate stream", () => {
+ const ws = TestBed.inject(WorkflowWebsocketService);
+ const initiated: string[] = [];
+ service.getResultInitiateStream().subscribe(op => initiated.push(op));
+
+ pushWsEvent(ws, {
+ type: "WebResultUpdateEvent",
+ updates: { pagOp: paginationUpdate(1), dataOp: snapshotUpdate([]) },
+ tableStats: {},
+ });
+
+ expect(initiated).toEqual(["pagOp", "dataOp"]);
+ });
+
+ it("feeds table stats to the matching paginated service and republishes the snapshot", () => {
+ const ws = TestBed.inject(WorkflowWebsocketService);
+ const statsPairs: [unknown, unknown][] = [];
+ service.getResultTableStats().subscribe(p => statsPairs.push(p));
+
+ const tableStats = { pagOp: { colA: { count: 5 } } };
+ pushWsEvent(ws, { type: "WebResultUpdateEvent", updates: { pagOp: paginationUpdate(3) }, tableStats });
+
+ expect(service.getPaginatedResultService("pagOp")!.getStats()).toEqual({ colA: { count: 5 } });
+ expect(statsPairs[statsPairs.length - 1][1]).toEqual(tableStats);
+ });
+
+ it("switches an operator from data output to paginated output and clears the stale service", () => {
+ const ws = TestBed.inject(WorkflowWebsocketService);
+ pushWsEvent(ws, { type: "WebResultUpdateEvent", updates: { op: snapshotUpdate([{ a: 1 }]) }, tableStats: {} });
+ expect(service.hasResult("op")).toBe(true);
+ expect(service.hasPaginatedResult("op")).toBe(false);
+
+ pushWsEvent(ws, { type: "WebResultUpdateEvent", updates: { op: paginationUpdate(9) }, tableStats: {} });
+ expect(service.hasPaginatedResult("op")).toBe(true);
+ expect(service.hasResult("op")).toBe(false);
+
+ pushWsEvent(ws, { type: "WebResultUpdateEvent", updates: { op: snapshotUpdate([{ b: 2 }]) }, tableStats: {} });
+ expect(service.hasResult("op")).toBe(true);
+ expect(service.hasPaginatedResult("op")).toBe(false);
+ });
+
+ it("handleCleanResultCache drops operators no longer available and resets invalidated caches", () => {
+ const ws = TestBed.inject(WorkflowWebsocketService);
+ pushWsEvent(ws, {
+ type: "WebResultUpdateEvent",
+ updates: {
+ goneData: snapshotUpdate([{ a: 1 }]),
+ gonePag: paginationUpdate(3),
+ resetData: snapshotUpdate([{ keep: 1 }]),
+ },
+ tableStats: {},
+ });
+ expect(service.getResultService("resetData")!.getCurrentResultSnapshot()).toEqual([{ keep: 1 }]);
+
+ pushWsEvent(ws, {
+ type: "WorkflowAvailableResultEvent",
+ availableOperators: {
+ keepPag: { cacheValid: true, outputMode: { type: "PaginationMode" } },
+ resetData: { cacheValid: false, outputMode: { type: "SetSnapshotMode" } },
+ },
+ });
+
+ // operators absent from the available set are dropped from both caches
+ expect(service.hasResult("goneData")).toBe(false);
+ expect(service.hasPaginatedResult("gonePag")).toBe(false);
+ // a freshly-available operator gets a new (paginated) service
+ expect(service.hasPaginatedResult("keepPag")).toBe(true);
+ // an available-but-invalidated operator keeps its service but has its cache reset
+ expect(service.hasResult("resetData")).toBe(true);
+ expect(service.getResultService("resetData")!.getCurrentResultSnapshot()).toBeUndefined();
+ });
+
+ it("handleCleanResultCache reports removed and invalidated operators on the result-update stream", () => {
+ const ws = TestBed.inject(WorkflowWebsocketService);
+ pushWsEvent(ws, {
+ type: "WebResultUpdateEvent",
+ updates: { goneData: snapshotUpdate([{ a: 1 }]) },
+ tableStats: {},
+ });
+
+ const updateEvents: Record<string, unknown>[] = [];
+ service.getResultUpdateStream().subscribe(u => updateEvents.push(u));
+
+ pushWsEvent(ws, {
+ type: "WorkflowAvailableResultEvent",
+ availableOperators: { resetPag: { cacheValid: false, outputMode: { type: "PaginationMode" } } },
+ });
+
+ expect(updateEvents.length).toBe(1);
+ expect(updateEvents[0]).toEqual({ goneData: undefined, resetPag: undefined });
+ });
+
+ it("determineOutputTypes distinguishes binary from non-binary paginated output", () => {
+ const ws = TestBed.inject(WorkflowWebsocketService);
+ pushWsEvent(ws, {
+ type: "WebResultUpdateEvent",
+ updates: { binOp: paginationUpdate(1), tblOp: paginationUpdate(1) },
+ tableStats: {},
+ });
+ (service.getPaginatedResultService("binOp") as any).schema = [{ attributeName: "b", attributeType: "binary" }];
+ (service.getPaginatedResultService("tblOp") as any).schema = [{ attributeName: "s", attributeType: "string" }];
+
+ expect(service.determineOutputTypes("binOp")).toEqual({
+ hasAnyResult: true,
+ isTableOutput: true,
+ containsBinaryData: true,
+ isVisualizationOutput: false,
+ });
+ expect(service.determineOutputTypes("tblOp")).toEqual({
+ hasAnyResult: true,
+ isTableOutput: true,
+ containsBinaryData: false,
+ isVisualizationOutput: false,
+ });
+ });
+
+ it("determineOutputTypes marks a data-only operator as visualization output", () => {
+ const ws = TestBed.inject(WorkflowWebsocketService);
+ pushWsEvent(ws, { type: "WebResultUpdateEvent", updates: { vizOp: snapshotUpdate([{ a: 1 }]) }, tableStats: {} });
+
+ expect(service.determineOutputTypes("vizOp")).toEqual({
+ hasAnyResult: true,
+ isTableOutput: false,
+ containsBinaryData: false,
+ isVisualizationOutput: true,
+ });
+ });
+
+ it("determineOutputTypes returns all-false for an operator with no results", () => {
+ expect(service.determineOutputTypes("missing")).toEqual({
+ hasAnyResult: false,
+ isTableOutput: false,
+ containsBinaryData: false,
+ isVisualizationOutput: false,
+ });
+ });
+
+ it("determineOutputExtension short-circuits the 'data' extension", () => {
+ expect(service.determineOutputExtension("anything", "data")).toBe("data");
+ });
+
+ it("determineOutputExtension returns html for visualization output", () => {
+ const ws = TestBed.inject(WorkflowWebsocketService);
+ pushWsEvent(ws, { type: "WebResultUpdateEvent", updates: { vizOp: snapshotUpdate([{ a: 1 }]) }, tableStats: {} });
+ expect(service.determineOutputExtension("vizOp", "csv")).toBe("html");
+ });
+
+ it("determineOutputExtension keeps csv for table output and passes other extensions through", () => {
+ const ws = TestBed.inject(WorkflowWebsocketService);
+ pushWsEvent(ws, { type: "WebResultUpdateEvent", updates: { tblOp: paginationUpdate(1) }, tableStats: {} });
+
+ expect(service.determineOutputExtension("tblOp", "csv")).toBe("csv");
+ expect(service.determineOutputExtension("tblOp", "json")).toBe("json");
+ // no result at all -> the (defaulted) extension is returned unchanged
+ expect(service.determineOutputExtension("missing")).toBe("csv");
+ });
});
describe("OperatorPaginationResultService", () => {
@@ -148,4 +347,140 @@ describe("OperatorPaginationResultService", () => {
expect(result.schema).toEqual(testSchema);
});
});
+
+ describe("selectPage", () => {
+ it("returns the cached page without contacting the server", () => {
+ const cachedPage = [{ id: 1 }, { id: 2 }];
+ (service as any).resultCache.set(2, cachedPage);
+ service["schema"] = [{ attributeName: "id", attributeType: "integer" }];
+
+ let result: PaginatedResultEvent | undefined;
+ service.selectPage(2, 10).subscribe(e => (result = e));
+
+ expect(mockWorkflowWebsocketService.send).not.toHaveBeenCalled();
+ expect(result).toEqual({
+ requestID: "",
+ operatorID: "testOperator",
+ pageIndex: 2,
+ table: cachedPage,
+ schema: [{ attributeName: "id", attributeType: "integer" }],
+ });
+ expect(service.getCurrentPageIndex()).toBe(2);
+ });
+
+ it("fetches from the server and resolves once the matching page event arrives", () => {
+ const eventSubject = mockWorkflowWebsocketService.subscribeToEvent.mock.results[0]
+ .value as unknown as Subject<PaginatedResultEvent>;
+ const received: PaginatedResultEvent[] = [];
+ service.selectPage(3, 10).subscribe(e => received.push(e));
+
+ expect(mockWorkflowWebsocketService.send).toHaveBeenCalledWith(
+ "ResultPaginationRequest",
+ expect.objectContaining({ operatorID: "testOperator", pageIndex: 3, pageSize: 10 })
+ );
+ expect(service.getCurrentPageIndex()).toBe(3);
+
+ const requestID = (mockWorkflowWebsocketService.send.mock.calls[0][1] as any).requestID;
+ const page: PaginatedResultEvent = {
+ requestID,
+ operatorID: "testOperator",
+ pageIndex: 3,
+ table: [{ id: 7 }],
+ schema: [{ attributeName: "id", attributeType: "integer" }],
+ };
+ eventSubject.next(page);
+
+ expect(received).toEqual([page]);
+ // schema is refreshed from the event and the pending request is cleared once completed
+ expect(service.getSchema()).toEqual([{ attributeName: "id", attributeType: "integer" }]);
+ expect((service as any).pendingRequests.size).toBe(0);
+ });
+
+ it("bypasses the frontend cache when column filters are set", () => {
+ (service as any).resultCache.set(2, [{ id: 1 }]);
+ service.selectPage(2, 10, 5).subscribe();
+
+ expect(mockWorkflowWebsocketService.send).toHaveBeenCalledWith(
+ "ResultPaginationRequest",
+ expect.objectContaining({ pageIndex: 2, columnOffset: 5 })
+ );
+ });
+ });
+
+ describe("handleResultUpdate", () => {
+ it("records the total tuple count and evicts only the dirty pages", () => {
+ (service as any).resultCache.set(1, [{ id: 1 }]);
+ (service as any).resultCache.set(2, [{ id: 2 }]);
+
+ service.handleResultUpdate({ mode: { type: "PaginationMode" }, totalNumTuples: 55, dirtyPageIndices: [2] });
+
+ expect(service.getCurrentTotalNumTuples()).toBe(55);
+ expect((service as any).resultCache.has(1)).toBe(true);
+ expect((service as any).resultCache.has(2)).toBe(false);
+ });
+ });
+
+ describe("handleStatsUpdate", () => {
+ it("rotates the current stats into the previous slot on each update", () => {
+ const first = { colA: { count: 1 } };
+ const second = { colA: { count: 2 } };
+
+ service.handleStatsUpdate(first);
+ expect(service.getStats()).toEqual(first);
+ expect(service.getPrevStats()).toEqual({});
+
+ service.handleStatsUpdate(second);
+ expect(service.getStats()).toEqual(second);
+ expect(service.getPrevStats()).toEqual(first);
+ });
+ });
+
+ describe("reset", () => {
+ it("clears caches, pending requests, page index and tuple total", () => {
+ (service as any).resultCache.set(1, [{ id: 1 }]);
+ (service as any).pendingRequests.set("req", new Subject());
+ (service as any).currentPageIndex = 5;
+ (service as any).currentTotalNumTuples = 99;
+
+ service.reset();
+
+ expect((service as any).resultCache.size).toBe(0);
+ expect((service as any).pendingRequests.size).toBe(0);
+ expect(service.getCurrentPageIndex()).toBe(1);
+ expect(service.getCurrentTotalNumTuples()).toBe(0);
+ });
+ });
+
+ describe("incoming page events", () => {
+ it("refreshes the schema but ignores events whose request id is unknown", () => {
+ const eventSubject = mockWorkflowWebsocketService.subscribeToEvent.mock.results[0]
+ .value as unknown as Subject<PaginatedResultEvent>;
+ const schema: SchemaAttribute[] = [{ attributeName: "name", attributeType: "string" }];
+
+ eventSubject.next({ requestID: "no-such-request", operatorID: "testOperator", pageIndex: 1, table: [], schema });
+
+ expect(service.getSchema()).toEqual(schema);
+ });
+ });
+});
+
+describe("OperatorResultService", () => {
+ it("exposes the latest snapshot and clears it on reset", () => {
+ const resultService = new OperatorResultService("op");
+ expect(resultService.getCurrentResultSnapshot()).toBeUndefined();
+
+ const table = [{ a: 1 }];
+ resultService.handleResultUpdate({ mode: { type: "SetSnapshotMode" }, table });
+ expect(resultService.getCurrentResultSnapshot()).toEqual(table);
+
+ resultService.reset();
+ expect(resultService.getCurrentResultSnapshot()).toBeUndefined();
+ });
+
+ it("ignores delta-mode updates and keeps the last snapshot", () => {
+ const resultService = new OperatorResultService("op");
+ resultService.handleResultUpdate({ mode: { type: "SetSnapshotMode" }, table: [{ a: 1 }] });
+ resultService.handleResultUpdate({ mode: { type: "SetDeltaMode" }, table: [{ b: 2 }] });
+ expect(resultService.getCurrentResultSnapshot()).toEqual([{ a: 1 }]);
+ });
});