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:46:34 UTC
(texera) 02/02: test(frontend): cover three dashboard services (#5203)
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
commit 767219abe30851cd5754dd10b93d467c9c90e4ec
Author: Yicong Huang <17...@users.noreply.github.com>
AuthorDate: Mon May 25 15:35:29 2026 -0700
test(frontend): cover three dashboard services (#5203)
### What changes were proposed in this PR?
Adds `HttpClientTestingModule`-based specs for three dashboard services
that previously had no (or near-empty) tests:
- `dataset.service.ts` — pins the URL, HTTP method, body / query string,
and response mapping for every public method, including the
authenticated / anonymous endpoint splits, the presigned-URL chain on
`retrieveDatasetVersionSingleFile`, and the file-node attaching that
`createDatasetVersion` / `retrieveDatasetLatestVersion` perform.
- `workflow-version.service.ts` — replaces a 2-test stub with coverage
of the readonly-display lifecycle (snapshot, swap, restore), joint-paper
highlight helpers, the forward-diff classification in
`getWorkflowsDifference` / `getOperatorsDifference`, and the three
version-API HTTP endpoints.
- `search.service.ts` — covers the authenticated vs public search
routing (with the forced `includePublic=true` on the anonymous path),
the dataset `hasMismatch` filtering inside `executeSearch`, and the
branchy enrich pipeline in `extendSearchResultsWithHubActivityInfo`
(Workflow / Project / Dataset entity routing, activity-list narrowing,
and the workflow-only size-lookup gating).
No production code is touched.
### Any related issues, documentation, discussions?
Closes #5202.
### How was this PR tested?
Added unit tests for the three services listed above.
### Was this PR authored or co-authored using generative AI tooling?
Generated-by: Claude Opus 4.7
Co-authored-by: Claude Opus 4.7 (1M context) <no...@anthropic.com>
---
.../service/user/dataset/dataset.service.spec.ts | 356 +++++++++++++++++
.../dashboard/service/user/search.service.spec.ts | 317 +++++++++++++++
.../workflow-version.service.spec.ts | 445 ++++++++++++++++++++-
3 files changed, 1099 insertions(+), 19 deletions(-)
diff --git a/frontend/src/app/dashboard/service/user/dataset/dataset.service.spec.ts b/frontend/src/app/dashboard/service/user/dataset/dataset.service.spec.ts
new file mode 100644
index 0000000000..d930e50835
--- /dev/null
+++ b/frontend/src/app/dashboard/service/user/dataset/dataset.service.spec.ts
@@ -0,0 +1,356 @@
+/**
+ * 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 { TestBed } from "@angular/core/testing";
+import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing";
+import { firstValueFrom } from "rxjs";
+
+import { DATASET_BASE_URL, DatasetService } from "./dataset.service";
+import { AppSettings } from "../../../../common/app-setting";
+import { commonTestProviders } from "../../../../common/testing/test-utils";
+import { Dataset, DatasetVersion } from "../../../../common/type/dataset";
+import { DashboardDataset } from "../../../type/dashboard-dataset.interface";
+import { DatasetFileNode } from "../../../../common/type/datasetVersionFileTree";
+import { DatasetStagedObject } from "../../../../common/type/dataset-staged-object";
+
+const API = "api";
+
+function buildDataset(overrides: Partial<Dataset> = {}): Dataset {
+ return {
+ did: 1,
+ ownerUid: 1,
+ name: "ds",
+ isPublic: false,
+ isDownloadable: false,
+ storagePath: undefined,
+ description: "",
+ creationTime: undefined,
+ coverImage: undefined,
+ ...overrides,
+ };
+}
+
+function buildDashboardDataset(overrides: Partial<DashboardDataset> = {}): DashboardDataset {
+ return {
+ isOwner: true,
+ ownerEmail: "owner@example.com",
+ dataset: buildDataset(),
+ accessPrivilege: "WRITE",
+ size: 0,
+ ...overrides,
+ };
+}
+
+function buildDatasetVersion(overrides: Partial<DatasetVersion> = {}): DatasetVersion {
+ return {
+ dvid: 5,
+ did: 1,
+ creatorUid: 1,
+ name: "v1",
+ versionHash: "abc",
+ creationTime: 0,
+ fileNodes: undefined,
+ ...overrides,
+ };
+}
+
+const SAMPLE_FILE_NODES: DatasetFileNode[] = [
+ { name: "root", type: "directory", parentDir: "", children: [] as DatasetFileNode[] } as DatasetFileNode,
+];
+
+describe("DatasetService", () => {
+ let service: DatasetService;
+ let http: HttpTestingController;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [HttpClientTestingModule],
+ providers: [DatasetService, ...commonTestProviders],
+ });
+ service = TestBed.inject(DatasetService);
+ http = TestBed.inject(HttpTestingController);
+ vi.spyOn(AppSettings, "getApiEndpoint").mockReturnValue(API);
+ });
+
+ afterEach(() => {
+ http.verify();
+ });
+
+ // ─── createDataset ────────────────────────────────────────────────────────
+
+ it("createDataset POSTs the dataset metadata under the create endpoint", async () => {
+ const dataset = buildDataset({ name: "demo", description: "desc", isPublic: true, isDownloadable: true });
+ const dashboard = buildDashboardDataset();
+ const pending = firstValueFrom(service.createDataset(dataset));
+
+ const req = http.expectOne(`${API}/${DATASET_BASE_URL}/create`);
+ expect(req.request.method).toBe("POST");
+ expect(req.request.body).toEqual({
+ datasetName: "demo",
+ datasetDescription: "desc",
+ isDatasetPublic: true,
+ isDatasetDownloadable: true,
+ });
+ req.flush(dashboard);
+ expect(await pending).toEqual(dashboard);
+ });
+
+ // ─── getDataset (login vs public branch) ──────────────────────────────────
+
+ it("getDataset hits /dataset/{did} when logged in", () => {
+ service.getDataset(7).subscribe();
+ http.expectOne(`${API}/${DATASET_BASE_URL}/7`).flush(buildDashboardDataset());
+ });
+
+ it("getDataset hits /dataset/public/{did} when anonymous", () => {
+ service.getDataset(7, false).subscribe();
+ http.expectOne(`${API}/${DATASET_BASE_URL}/public/7`).flush(buildDashboardDataset());
+ });
+
+ // ─── retrieveDatasetVersionSingleFile (chained presigned URL) ─────────────
+
+ it("retrieveDatasetVersionSingleFile resolves the presigned URL then GETs the blob", async () => {
+ const filePath = "folder/file.txt";
+ const blob = new Blob(["bytes"]);
+ const pending = firstValueFrom(service.retrieveDatasetVersionSingleFile(filePath));
+
+ const presignReq = http.expectOne(
+ `${API}/${DATASET_BASE_URL}/presign-download?filePath=${encodeURIComponent(filePath)}`
+ );
+ expect(presignReq.request.method).toBe("GET");
+ presignReq.flush({ presignedUrl: "https://s3.example/blob" });
+
+ const blobReq = http.expectOne("https://s3.example/blob");
+ expect(blobReq.request.responseType).toBe("blob");
+ blobReq.flush(blob);
+
+ expect(await pending).toBe(blob);
+ });
+
+ it("retrieveDatasetVersionSingleFile uses the public presign endpoint when anonymous", () => {
+ const filePath = "f.txt";
+ service.retrieveDatasetVersionSingleFile(filePath, false).subscribe();
+ const presignReq = http.expectOne(
+ `${API}/${DATASET_BASE_URL}/public-presign-download?filePath=${encodeURIComponent(filePath)}`
+ );
+ presignReq.flush({ presignedUrl: "https://s3.example/x" });
+ http.expectOne("https://s3.example/x").flush(new Blob());
+ });
+
+ // ─── retrieveDatasetVersionZip ────────────────────────────────────────────
+
+ it("retrieveDatasetVersionZip sets dvid when a version is specified", () => {
+ service.retrieveDatasetVersionZip(3, 99).subscribe();
+ const req = http.expectOne(r => r.url === `${API}/dataset/3/versionZip`);
+ expect(req.request.params.get("dvid")).toBe("99");
+ expect(req.request.params.get("latest")).toBeNull();
+ expect(req.request.responseType).toBe("blob");
+ req.flush(new Blob());
+ });
+
+ it("retrieveDatasetVersionZip sets latest=true when dvid is omitted", () => {
+ service.retrieveDatasetVersionZip(3).subscribe();
+ const req = http.expectOne(r => r.url === `${API}/dataset/3/versionZip`);
+ expect(req.request.params.get("latest")).toBe("true");
+ expect(req.request.params.get("dvid")).toBeNull();
+ req.flush(new Blob());
+ });
+
+ // ─── retrieveAccessibleDatasets ───────────────────────────────────────────
+
+ it("retrieveAccessibleDatasets GETs /dataset/list", async () => {
+ const datasets = [buildDashboardDataset()];
+ const pending = firstValueFrom(service.retrieveAccessibleDatasets());
+ http.expectOne(`${API}/${DATASET_BASE_URL}/list`).flush(datasets);
+ expect(await pending).toEqual(datasets);
+ });
+
+ // ─── createDatasetVersion (mapper attaches fileNodes) ─────────────────────
+
+ it("createDatasetVersion attaches fileNodes onto the returned DatasetVersion", async () => {
+ const did = 1;
+ const newVersion = "v2";
+ const dv = buildDatasetVersion({ name: newVersion });
+ const pending = firstValueFrom(service.createDatasetVersion(did, newVersion));
+
+ const req = http.expectOne(`${API}/${DATASET_BASE_URL}/${did}/version/create`);
+ expect(req.request.method).toBe("POST");
+ expect(req.request.body).toBe(newVersion);
+ expect(req.request.headers.get("Content-Type")).toBe("text/plain");
+ req.flush({ datasetVersion: dv, fileNodes: SAMPLE_FILE_NODES });
+
+ const result = await pending;
+ expect(result.fileNodes).toBe(SAMPLE_FILE_NODES);
+ expect(result.name).toBe(newVersion);
+ });
+
+ // ─── listMultipartUploads ─────────────────────────────────────────────────
+
+ it("listMultipartUploads returns the filePaths array", async () => {
+ const pending = firstValueFrom(service.listMultipartUploads("a@b.com", "ds"));
+ const req = http.expectOne(r => r.url === `${API}/${DATASET_BASE_URL}/multipart-upload`);
+ expect(req.request.method).toBe("POST");
+ expect(req.request.params.get("type")).toBe("list");
+ expect(req.request.params.get("ownerEmail")).toBe("a@b.com");
+ expect(req.request.params.get("datasetName")).toBe("ds");
+ req.flush({ filePaths: ["a", "b"] });
+ expect(await pending).toEqual(["a", "b"]);
+ });
+
+ it("listMultipartUploads tolerates a null payload", async () => {
+ const pending = firstValueFrom(service.listMultipartUploads("a@b.com", "ds"));
+ const req = http.expectOne(r => r.url === `${API}/${DATASET_BASE_URL}/multipart-upload`);
+ req.flush(null);
+ expect(await pending).toEqual([]);
+ });
+
+ // ─── finalizeMultipartUpload (abort vs finish) ────────────────────────────
+
+ it("finalizeMultipartUpload routes through type=finish when not aborting", () => {
+ service.finalizeMultipartUpload("a@b.com", "ds", "f", false).subscribe();
+ const req = http.expectOne(r => r.url === `${API}/${DATASET_BASE_URL}/multipart-upload`);
+ expect(req.request.params.get("type")).toBe("finish");
+ expect(req.request.params.get("filePath")).toBe(encodeURIComponent("f"));
+ req.flush({});
+ });
+
+ it("finalizeMultipartUpload routes through type=abort when aborting", () => {
+ service.finalizeMultipartUpload("a@b.com", "ds", "f", true).subscribe();
+ const req = http.expectOne(r => r.url === `${API}/${DATASET_BASE_URL}/multipart-upload`);
+ expect(req.request.params.get("type")).toBe("abort");
+ req.flush({});
+ });
+
+ // ─── resetDatasetFileDiff / deleteDatasetFile ─────────────────────────────
+
+ it("resetDatasetFileDiff PUTs /dataset/{did}/diff with the encoded filePath", () => {
+ service.resetDatasetFileDiff(2, "a/b.txt").subscribe();
+ const req = http.expectOne(r => r.url === `${API}/${DATASET_BASE_URL}/2/diff`);
+ expect(req.request.method).toBe("PUT");
+ expect(req.request.params.get("filePath")).toBe(encodeURIComponent("a/b.txt"));
+ req.flush({});
+ });
+
+ it("deleteDatasetFile DELETEs /dataset/{did}/file with the encoded filePath", () => {
+ service.deleteDatasetFile(2, "a/b.txt").subscribe();
+ const req = http.expectOne(r => r.url === `${API}/${DATASET_BASE_URL}/2/file`);
+ expect(req.request.method).toBe("DELETE");
+ expect(req.request.params.get("filePath")).toBe(encodeURIComponent("a/b.txt"));
+ req.flush({});
+ });
+
+ // ─── getDatasetDiff ───────────────────────────────────────────────────────
+
+ it("getDatasetDiff returns the staged-object list", async () => {
+ const diff: DatasetStagedObject[] = [{ path: "p", pathType: "file", diffType: "added" }];
+ const pending = firstValueFrom(service.getDatasetDiff(9));
+ http.expectOne(`${API}/${DATASET_BASE_URL}/9/diff`).flush(diff);
+ expect(await pending).toEqual(diff);
+ });
+
+ // ─── retrieveDatasetVersionList (login vs public) ─────────────────────────
+
+ it("retrieveDatasetVersionList hits the authenticated path when logged in", () => {
+ service.retrieveDatasetVersionList(1).subscribe();
+ http.expectOne(`${API}/${DATASET_BASE_URL}/1/version/list`).flush([]);
+ });
+
+ it("retrieveDatasetVersionList hits the public path when anonymous", () => {
+ service.retrieveDatasetVersionList(1, false).subscribe();
+ http.expectOne(`${API}/${DATASET_BASE_URL}/1/publicVersion/list`).flush([]);
+ });
+
+ // ─── retrieveDatasetLatestVersion (mapper attaches fileNodes) ─────────────
+
+ it("retrieveDatasetLatestVersion attaches fileNodes onto the returned version", async () => {
+ const dv = buildDatasetVersion();
+ const pending = firstValueFrom(service.retrieveDatasetLatestVersion(1));
+ const req = http.expectOne(`${API}/${DATASET_BASE_URL}/1/version/latest`);
+ req.flush({ datasetVersion: dv, fileNodes: SAMPLE_FILE_NODES });
+ const result = await pending;
+ expect(result.fileNodes).toBe(SAMPLE_FILE_NODES);
+ });
+
+ // ─── retrieveDatasetVersionFileTree (login vs public) ─────────────────────
+
+ it("retrieveDatasetVersionFileTree picks the authenticated path when logged in", () => {
+ service.retrieveDatasetVersionFileTree(1, 2).subscribe();
+ http.expectOne(`${API}/${DATASET_BASE_URL}/1/version/2/rootFileNodes`).flush({ fileNodes: [], size: 0 });
+ });
+
+ it("retrieveDatasetVersionFileTree picks the public path when anonymous", () => {
+ service.retrieveDatasetVersionFileTree(1, 2, false).subscribe();
+ http.expectOne(`${API}/${DATASET_BASE_URL}/1/publicVersion/2/rootFileNodes`).flush({ fileNodes: [], size: 0 });
+ });
+
+ // ─── deleteDatasets ───────────────────────────────────────────────────────
+
+ it("deleteDatasets DELETEs /dataset/{did}", () => {
+ service.deleteDatasets(5).subscribe();
+ const req = http.expectOne(`${API}/${DATASET_BASE_URL}/5`);
+ expect(req.request.method).toBe("DELETE");
+ req.flush({});
+ });
+
+ // ─── updateDatasetName / Description / Publicity / Downloadable ───────────
+
+ it("updateDatasetName POSTs name + did into /update/name", () => {
+ service.updateDatasetName(2, "renamed").subscribe();
+ const req = http.expectOne(`${API}/${DATASET_BASE_URL}/update/name`);
+ expect(req.request.method).toBe("POST");
+ expect(req.request.body).toEqual({ did: 2, name: "renamed" });
+ req.flush({});
+ });
+
+ it("updateDatasetDescription POSTs description + did into /update/description", () => {
+ service.updateDatasetDescription(2, "newdesc").subscribe();
+ const req = http.expectOne(`${API}/${DATASET_BASE_URL}/update/description`);
+ expect(req.request.body).toEqual({ did: 2, description: "newdesc" });
+ req.flush({});
+ });
+
+ it("updateDatasetPublicity POSTs to /dataset/{did}/update/publicity", () => {
+ service.updateDatasetPublicity(2).subscribe();
+ const req = http.expectOne(`${API}/${DATASET_BASE_URL}/2/update/publicity`);
+ expect(req.request.method).toBe("POST");
+ expect(req.request.body).toEqual({});
+ req.flush({});
+ });
+
+ it("updateDatasetDownloadable POSTs to /dataset/{did}/update/downloadable", () => {
+ service.updateDatasetDownloadable(2).subscribe();
+ const req = http.expectOne(`${API}/${DATASET_BASE_URL}/2/update/downloadable`);
+ req.flush({});
+ });
+
+ // ─── retrieveOwners / updateDatasetCoverImage ─────────────────────────────
+
+ it("retrieveOwners GETs /dataset/user-dataset-owners", async () => {
+ const pending = firstValueFrom(service.retrieveOwners());
+ http.expectOne(`${API}/${DATASET_BASE_URL}/user-dataset-owners`).flush(["a", "b"]);
+ expect(await pending).toEqual(["a", "b"]);
+ });
+
+ it("updateDatasetCoverImage POSTs the cover image base64 to /dataset/{did}/update/cover", () => {
+ service.updateDatasetCoverImage(3, "data:image/png;base64,ZGF0YQ==").subscribe();
+ const req = http.expectOne(`${API}/dataset/3/update/cover`);
+ expect(req.request.body).toEqual({ coverImage: "data:image/png;base64,ZGF0YQ==" });
+ req.flush({});
+ });
+});
diff --git a/frontend/src/app/dashboard/service/user/search.service.spec.ts b/frontend/src/app/dashboard/service/user/search.service.spec.ts
new file mode 100644
index 0000000000..c80df0441b
--- /dev/null
+++ b/frontend/src/app/dashboard/service/user/search.service.spec.ts
@@ -0,0 +1,317 @@
+/**
+ * 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 { TestBed } from "@angular/core/testing";
+import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing";
+import { firstValueFrom, of } from "rxjs";
+
+import { SearchService } from "./search.service";
+import { AppSettings } from "../../../common/app-setting";
+import { commonTestProviders } from "../../../common/testing/test-utils";
+import { ActionType, EntityType, HubService } from "../../../hub/service/hub.service";
+import { WorkflowPersistService } from "../../../common/service/workflow-persist/workflow-persist.service";
+import { SearchFilterParameters } from "../../type/search-filter-parameters";
+import { SortMethod } from "../../type/sort-method";
+import { SearchResult, SearchResultItem } from "../../type/search-result";
+import { DashboardWorkflow } from "../../type/dashboard-workflow.interface";
+import { DashboardProject } from "../../type/dashboard-project.interface";
+import { DashboardDataset } from "../../type/dashboard-dataset.interface";
+
+const API = "api";
+
+function makeEmptyFilter(): SearchFilterParameters {
+ return {
+ createDateStart: null,
+ createDateEnd: null,
+ modifiedDateStart: null,
+ modifiedDateEnd: null,
+ owners: [],
+ ids: [],
+ operators: [],
+ projectIds: [],
+ };
+}
+
+function makeWorkflowItem(wid: number, ownerId: number): SearchResultItem {
+ const workflow: DashboardWorkflow = {
+ isOwner: true,
+ ownerName: undefined,
+ workflow: {
+ name: `wf-${wid}`,
+ description: undefined,
+ wid,
+ creationTime: 0,
+ lastModifiedTime: 0,
+ isPublished: 0,
+ readonly: false,
+ content: {
+ operators: [],
+ operatorPositions: {},
+ links: [],
+ commentBoxes: [],
+ settings: { dataTransferBatchSize: 400, executionMode: "PIPELINED" as any },
+ },
+ },
+ projectIDs: [],
+ accessLevel: "WRITE",
+ ownerId,
+ };
+ return { resourceType: "workflow", workflow };
+}
+
+function makeProjectItem(pid: number, ownerId: number): SearchResultItem {
+ const project: DashboardProject = {
+ pid,
+ name: `proj-${pid}`,
+ description: "",
+ ownerId,
+ creationTime: 0,
+ color: null,
+ accessLevel: "WRITE",
+ };
+ return { resourceType: "project", project };
+}
+
+function makeDatasetItem(did: number, ownerUid: number): SearchResultItem {
+ const dataset: DashboardDataset = {
+ isOwner: true,
+ ownerEmail: "o@example.com",
+ accessPrivilege: "WRITE",
+ size: 17,
+ dataset: {
+ did,
+ ownerUid,
+ name: `ds-${did}`,
+ isPublic: false,
+ isDownloadable: false,
+ storagePath: undefined,
+ description: "",
+ creationTime: 0,
+ coverImage: undefined,
+ },
+ };
+ return { resourceType: "dataset", dataset };
+}
+
+describe("SearchService", () => {
+ let service: SearchService;
+ let http: HttpTestingController;
+ let hubSpy: {
+ getCounts: ReturnType<typeof vi.fn>;
+ isLiked: ReturnType<typeof vi.fn>;
+ getUserAccess: ReturnType<typeof vi.fn>;
+ };
+ let persistSpy: { getSizes: ReturnType<typeof vi.fn> };
+
+ beforeEach(() => {
+ hubSpy = {
+ getCounts: vi.fn().mockReturnValue(of([])),
+ isLiked: vi.fn().mockReturnValue(of([])),
+ getUserAccess: vi.fn().mockReturnValue(of([])),
+ };
+ persistSpy = { getSizes: vi.fn().mockReturnValue(of({})) };
+
+ TestBed.configureTestingModule({
+ imports: [HttpClientTestingModule],
+ providers: [
+ SearchService,
+ { provide: HubService, useValue: hubSpy },
+ { provide: WorkflowPersistService, useValue: persistSpy },
+ ...commonTestProviders,
+ ],
+ });
+ service = TestBed.inject(SearchService);
+ http = TestBed.inject(HttpTestingController);
+ vi.spyOn(AppSettings, "getApiEndpoint").mockReturnValue(API);
+ });
+
+ afterEach(() => {
+ http.verify();
+ });
+
+ // ─── search ───────────────────────────────────────────────────────────────
+
+ describe("search", () => {
+ it("hits dashboard/search with includePublic=true when logged in and asking for public", async () => {
+ const result: SearchResult = { results: [], more: false };
+ const pending = firstValueFrom(
+ service.search(["k"], makeEmptyFilter(), 0, 10, "workflow", SortMethod.NameAsc, true, true)
+ );
+ const req = http.expectOne(r => r.url.startsWith(`${API}/dashboard/search`));
+ expect(req.request.method).toBe("GET");
+ expect(req.request.url).toContain("includePublic=true");
+ req.flush(result);
+ expect(await pending).toEqual(result);
+ });
+
+ it("hits dashboard/search with includePublic=false when logged in and asking for private only", () => {
+ service.search(["k"], makeEmptyFilter(), 0, 10, "workflow", SortMethod.NameAsc, true, false).subscribe();
+ const req = http.expectOne(r => r.url.startsWith(`${API}/dashboard/search`));
+ expect(req.request.url).toContain("includePublic=false");
+ req.flush({ results: [], more: false });
+ });
+
+ it("hits dashboard/publicSearch and forces includePublic=true when anonymous", () => {
+ service.search(["k"], makeEmptyFilter(), 0, 10, "workflow", SortMethod.NameAsc, false, false).subscribe();
+ const req = http.expectOne(r => r.url.startsWith(`${API}/dashboard/publicSearch`));
+ expect(req.request.url).toContain("includePublic=true");
+ req.flush({ results: [], more: false });
+ });
+ });
+
+ // ─── getUserInfo ──────────────────────────────────────────────────────────
+
+ it("getUserInfo encodes each user id as a repeated `userIds` query param", async () => {
+ const pending = firstValueFrom(service.getUserInfo([1, 2]));
+ const req = http.expectOne(r => r.url.startsWith(`${API}/dashboard/resultsOwnersInfo`));
+ expect(req.request.urlWithParams).toContain("userIds=1");
+ expect(req.request.urlWithParams).toContain("userIds=2");
+ req.flush({ 1: { userName: "alice" } });
+ expect(await pending).toEqual({ 1: { userName: "alice" } });
+ });
+
+ // ─── executeSearch ────────────────────────────────────────────────────────
+
+ describe("executeSearch", () => {
+ it("filters null/mismatched datasets and surfaces hasMismatch", async () => {
+ const dsItem = makeDatasetItem(10, 5);
+ const flagged: any = { resourceType: "dataset", dataset: null };
+ const result: SearchResult = { results: [dsItem, flagged, null as any], more: true, hasMismatch: true };
+ vi.spyOn(service, "search").mockReturnValue(of(result));
+ vi.spyOn(service, "getUserInfo").mockReturnValue(of({} as any));
+
+ const batch = await firstValueFrom(
+ service.executeSearch([], makeEmptyFilter(), 0, 10, "dataset", SortMethod.NameAsc, true, false)
+ );
+
+ expect(batch.entries).toHaveLength(1);
+ expect(batch.entries[0].id).toBe(10);
+ expect(batch.more).toBe(true);
+ expect(batch.hasMismatch).toBe(true);
+ });
+
+ it("leaves hasMismatch undefined and skips filtering for non-dataset searches", async () => {
+ const wf = makeWorkflowItem(11, 7);
+ vi.spyOn(service, "search").mockReturnValue(of({ results: [wf], more: false, hasMismatch: true }));
+ vi.spyOn(service, "getUserInfo").mockReturnValue(of({} as any));
+
+ const batch = await firstValueFrom(
+ service.executeSearch([], makeEmptyFilter(), 0, 10, "workflow", SortMethod.NameAsc, true, false)
+ );
+
+ expect(batch.hasMismatch).toBeUndefined();
+ expect(batch.entries).toHaveLength(1);
+ });
+ });
+
+ // ─── extendSearchResultsWithHubActivityInfo ───────────────────────────────
+
+ describe("extendSearchResultsWithHubActivityInfo", () => {
+ it("skips hub fetches and persist size lookup when there are no items", async () => {
+ const entries = await firstValueFrom(service.extendSearchResultsWithHubActivityInfo([], true));
+ expect(entries).toEqual([]);
+ expect(hubSpy.getCounts).not.toHaveBeenCalled();
+ expect(hubSpy.isLiked).not.toHaveBeenCalled();
+ expect(hubSpy.getUserAccess).not.toHaveBeenCalled();
+ expect(persistSpy.getSizes).not.toHaveBeenCalled();
+ });
+
+ it("hydrates counts, like flags, access ids, and sizes for a workflow item", async () => {
+ const wf = makeWorkflowItem(11, 7);
+ hubSpy.getCounts.mockReturnValue(
+ of([{ entityId: 11, entityType: EntityType.Workflow, counts: { [ActionType.View]: 3, [ActionType.Like]: 1 } }])
+ );
+ hubSpy.isLiked.mockReturnValue(of([{ entityId: 11, entityType: EntityType.Workflow, isLiked: true }]));
+ hubSpy.getUserAccess.mockReturnValue(of([{ entityId: 11, entityType: EntityType.Workflow, userIds: [99] }]));
+ persistSpy.getSizes.mockReturnValue(of({ 11: 4096 }));
+
+ const userInfoSpy = vi.spyOn(service, "getUserInfo").mockReturnValue(of({ 7: { userName: "alice" } }));
+
+ const [entry] = await firstValueFrom(service.extendSearchResultsWithHubActivityInfo([wf], true));
+
+ expect(userInfoSpy).toHaveBeenCalledWith([7]);
+ expect(entry.viewCount).toBe(3);
+ expect(entry.likeCount).toBe(1);
+ expect(entry.isLiked).toBe(true);
+ expect(entry.accessibleUserIds).toEqual([99]);
+ expect(entry.size).toBe(4096);
+ expect(entry.ownerName).toBe("alice");
+ });
+
+ it("skips like lookup when the user is not logged in", async () => {
+ const wf = makeWorkflowItem(11, 7);
+ vi.spyOn(service, "getUserInfo").mockReturnValue(of({} as any));
+
+ const [entry] = await firstValueFrom(service.extendSearchResultsWithHubActivityInfo([wf], false));
+
+ expect(hubSpy.isLiked).not.toHaveBeenCalled();
+ expect(entry.isLiked).toBe(false);
+ });
+
+ it("honors a narrowed activities list (counts only)", async () => {
+ const wf = makeWorkflowItem(11, 7);
+ vi.spyOn(service, "getUserInfo").mockReturnValue(of({} as any));
+
+ await firstValueFrom(service.extendSearchResultsWithHubActivityInfo([wf], true, ["counts"]));
+
+ expect(hubSpy.getCounts).toHaveBeenCalled();
+ expect(hubSpy.isLiked).not.toHaveBeenCalled();
+ expect(hubSpy.getUserAccess).not.toHaveBeenCalled();
+ expect(persistSpy.getSizes).not.toHaveBeenCalled();
+ });
+
+ it("uses Project entity routing for project items", async () => {
+ const proj = makeProjectItem(20, 8);
+ vi.spyOn(service, "getUserInfo").mockReturnValue(of({ 8: { userName: "bob" } }));
+ hubSpy.getCounts.mockReturnValue(
+ of([{ entityId: 20, entityType: EntityType.Project, counts: { [ActionType.Clone]: 2 } }])
+ );
+
+ const [entry] = await firstValueFrom(service.extendSearchResultsWithHubActivityInfo([proj], true));
+
+ const [types, ids] = hubSpy.getCounts.mock.calls[0];
+ expect(types).toEqual([EntityType.Project]);
+ expect(ids).toEqual([20]);
+ expect(entry.cloneCount).toBe(2);
+ expect(entry.ownerName).toBe("bob");
+ });
+
+ it("uses Dataset entity routing and pulls ownerUid for dataset items", async () => {
+ const ds = makeDatasetItem(30, 9);
+ const userInfoSpy = vi.spyOn(service, "getUserInfo").mockReturnValue(of({ 9: { userName: "carol" } }));
+ hubSpy.getUserAccess.mockReturnValue(of([{ entityId: 30, entityType: EntityType.Dataset, userIds: [42, 43] }]));
+
+ const [entry] = await firstValueFrom(service.extendSearchResultsWithHubActivityInfo([ds], true));
+
+ expect(userInfoSpy).toHaveBeenCalledWith([9]);
+ expect(hubSpy.getUserAccess.mock.calls[0][0]).toEqual([EntityType.Dataset]);
+ expect(entry.accessibleUserIds).toEqual([42, 43]);
+ expect(entry.ownerName).toBe("carol");
+ });
+
+ it("does not request sizes when there are no workflow items", async () => {
+ const proj = makeProjectItem(20, 8);
+ vi.spyOn(service, "getUserInfo").mockReturnValue(of({} as any));
+
+ await firstValueFrom(service.extendSearchResultsWithHubActivityInfo([proj], true));
+
+ expect(persistSpy.getSizes).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/frontend/src/app/dashboard/service/user/workflow-version/workflow-version.service.spec.ts b/frontend/src/app/dashboard/service/user/workflow-version/workflow-version.service.spec.ts
index 37e73beca4..85c9016500 100644
--- a/frontend/src/app/dashboard/service/user/workflow-version/workflow-version.service.spec.ts
+++ b/frontend/src/app/dashboard/service/user/workflow-version/workflow-version.service.spec.ts
@@ -18,44 +18,451 @@
*/
import { TestBed } from "@angular/core/testing";
-import { HttpClientTestingModule } from "@angular/common/http/testing";
-import { WorkflowVersionService } from "./workflow-version.service";
-import { WorkflowActionService } from "src/app/workspace/service/workflow-graph/model/workflow-action.service";
-import { OperatorMetadataService } from "src/app/workspace/service/operator-metadata/operator-metadata.service";
+import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing";
+import { firstValueFrom } from "rxjs";
+import { take, toArray } from "rxjs/operators";
+
+import { WORKFLOW_VERSIONS_API_BASE_URL, WorkflowVersionService } from "./workflow-version.service";
+import { WorkflowActionService } from "../../../../workspace/service/workflow-graph/model/workflow-action.service";
+import { WorkflowPersistService } from "../../../../common/service/workflow-persist/workflow-persist.service";
+import { UndoRedoService } from "../../../../workspace/service/undo-redo/undo-redo.service";
+import { AppSettings } from "../../../../common/app-setting";
import { commonTestProviders } from "../../../../common/testing/test-utils";
+import { ExecutionMode, Workflow, WorkflowContent } from "../../../../common/type/workflow";
+import { CommentBox, OperatorLink, OperatorPredicate } from "../../../../workspace/types/workflow-common.interface";
+import { WorkflowVersionEntry } from "../../../type/workflow-version-entry";
+
+const API = "api";
+
+function buildOperator(overrides: Partial<OperatorPredicate> = {}): OperatorPredicate {
+ return {
+ operatorID: "op-1",
+ operatorType: "Filter",
+ operatorVersion: "v1",
+ operatorProperties: {},
+ inputPorts: [],
+ outputPorts: [],
+ showAdvanced: false,
+ ...overrides,
+ };
+}
+
+function buildLink(source: string, target: string): OperatorLink {
+ return {
+ linkID: `${source}->${target}`,
+ source: { operatorID: source, portID: "out-0" },
+ target: { operatorID: target, portID: "in-0" },
+ };
+}
+
+function buildContent(overrides: Partial<WorkflowContent> = {}): WorkflowContent {
+ return {
+ operators: [],
+ operatorPositions: {},
+ links: [],
+ commentBoxes: [] as CommentBox[],
+ settings: { dataTransferBatchSize: 400, executionMode: ExecutionMode.PIPELINED },
+ ...overrides,
+ };
+}
+
+function buildWorkflow(overrides: Partial<Workflow> = {}): Workflow {
+ return {
+ name: "wf",
+ description: undefined,
+ wid: 1,
+ creationTime: 0,
+ lastModifiedTime: 0,
+ isPublished: 0,
+ readonly: false,
+ content: buildContent(),
+ ...overrides,
+ };
+}
describe("WorkflowVersionService", () => {
let service: WorkflowVersionService;
+ let http: HttpTestingController;
+ let actionSpy: {
+ getJointGraphWrapper: ReturnType<typeof vi.fn>;
+ getWorkflow: ReturnType<typeof vi.fn>;
+ getWorkflowContent: ReturnType<typeof vi.fn>;
+ setTempWorkflow: ReturnType<typeof vi.fn>;
+ getTempWorkflow: ReturnType<typeof vi.fn>;
+ resetTempWorkflow: ReturnType<typeof vi.fn>;
+ reloadWorkflow: ReturnType<typeof vi.fn>;
+ enableWorkflowModification: ReturnType<typeof vi.fn>;
+ disableWorkflowModification: ReturnType<typeof vi.fn>;
+ checkWorkflowModificationEnabled: ReturnType<typeof vi.fn>;
+ };
+ let persistSpy: { setWorkflowPersistFlag: ReturnType<typeof vi.fn> };
+ let undoRedoSpy: {
+ clearRedoStack: ReturnType<typeof vi.fn>;
+ clearUndoStack: ReturnType<typeof vi.fn>;
+ enableWorkFlowModification: ReturnType<typeof vi.fn>;
+ disableWorkFlowModification: ReturnType<typeof vi.fn>;
+ };
+ let highlightedSpy: { getCurrentHighlights: ReturnType<typeof vi.fn>; unhighlightElements: ReturnType<typeof vi.fn> };
+ let modelAttr: ReturnType<typeof vi.fn>;
+ let paperGetModelById: ReturnType<typeof vi.fn>;
+ let getMainJointPaper: ReturnType<typeof vi.fn>;
beforeEach(() => {
+ modelAttr = vi.fn();
+ paperGetModelById = vi.fn().mockReturnValue({ attr: modelAttr });
+ getMainJointPaper = vi.fn().mockReturnValue({ getModelById: paperGetModelById });
+ highlightedSpy = {
+ getCurrentHighlights: vi.fn().mockReturnValue(["a", "b"]),
+ unhighlightElements: vi.fn(),
+ };
+ actionSpy = {
+ getJointGraphWrapper: vi.fn().mockReturnValue({
+ getCurrentHighlights: highlightedSpy.getCurrentHighlights,
+ unhighlightElements: highlightedSpy.unhighlightElements,
+ getMainJointPaper,
+ }),
+ getWorkflow: vi.fn().mockReturnValue(buildWorkflow()),
+ getWorkflowContent: vi.fn().mockReturnValue(buildContent()),
+ setTempWorkflow: vi.fn(),
+ getTempWorkflow: vi.fn().mockReturnValue(undefined),
+ resetTempWorkflow: vi.fn(),
+ reloadWorkflow: vi.fn(),
+ enableWorkflowModification: vi.fn(),
+ disableWorkflowModification: vi.fn(),
+ checkWorkflowModificationEnabled: vi.fn().mockReturnValue(true),
+ };
+ persistSpy = { setWorkflowPersistFlag: vi.fn() };
+ undoRedoSpy = {
+ clearRedoStack: vi.fn(),
+ clearUndoStack: vi.fn(),
+ enableWorkFlowModification: vi.fn(),
+ disableWorkFlowModification: vi.fn(),
+ };
+
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
- providers: [WorkflowVersionService, WorkflowActionService, OperatorMetadataService, ...commonTestProviders],
+ providers: [
+ WorkflowVersionService,
+ { provide: WorkflowActionService, useValue: actionSpy },
+ { provide: WorkflowPersistService, useValue: persistSpy },
+ { provide: UndoRedoService, useValue: undoRedoSpy },
+ ...commonTestProviders,
+ ],
});
service = TestBed.inject(WorkflowVersionService);
+ http = TestBed.inject(HttpTestingController);
+ vi.spyOn(AppSettings, "getApiEndpoint").mockReturnValue(API);
+ });
+
+ afterEach(() => {
+ http.verify();
});
+ // ─── canRestoreVersion getter ─────────────────────────────────────────────
+
describe("canRestoreVersion", () => {
- it("should return true when modificationEnabledBeforeTempWorkflow is true", () => {
- // Arrange
- service["modificationEnabledBeforeTempWorkflow"] = true;
+ it("is false when no readonly display has been entered", () => {
+ expect(service.canRestoreVersion).toBe(false);
+ });
+
+ it("is true after entering readonly display from a modification-enabled state", () => {
+ actionSpy.checkWorkflowModificationEnabled.mockReturnValue(true);
+ service.displayReadonlyWorkflow(buildWorkflow());
+ expect(service.canRestoreVersion).toBe(true);
+ });
+
+ it("is false after entering readonly display from an already-disabled state", () => {
+ actionSpy.checkWorkflowModificationEnabled.mockReturnValue(false);
+ service.displayReadonlyWorkflow(buildWorkflow());
+ expect(service.canRestoreVersion).toBe(false);
+ });
+ });
+
+ // ─── setDisplayParticularVersion + streams ────────────────────────────────
+
+ describe("setDisplayParticularVersion", () => {
+ it("publishes both ids and the flag when entering a particular version", async () => {
+ const flag = firstValueFrom(service.getDisplayParticularVersionStream().pipe(take(2), toArray()));
+ service.setDisplayParticularVersion(true, 7, 9);
+ expect(service.selectedVersionId.getValue()).toBe(7);
+ expect(service.selectedDisplayedVersionId.getValue()).toBe(9);
+ expect(await flag).toEqual([false, true]);
+ });
+
+ it("ignores undefined version ids but still flips the flag", () => {
+ service.setDisplayParticularVersion(true);
+ expect(service.selectedVersionId.getValue()).toBeNull();
+ expect(service.selectedDisplayedVersionId.getValue()).toBeNull();
+ });
+
+ it("nulls both ids when leaving a particular version", () => {
+ service.setDisplayParticularVersion(true, 7, 9);
+ service.setDisplayParticularVersion(false);
+ expect(service.selectedVersionId.getValue()).toBeNull();
+ expect(service.selectedDisplayedVersionId.getValue()).toBeNull();
+ });
+ });
- // Act
- const result = service.canRestoreVersion;
+ // ─── displayWorkflowVersions ──────────────────────────────────────────────
- // Assert
- expect(result).toBe(true);
+ it("displayWorkflowVersions unhighlights whatever is currently highlighted", () => {
+ service.displayWorkflowVersions();
+ expect(highlightedSpy.getCurrentHighlights).toHaveBeenCalled();
+ expect(highlightedSpy.unhighlightElements).toHaveBeenCalledWith(["a", "b"]);
+ });
+
+ // ─── displayReadonlyWorkflow ──────────────────────────────────────────────
+
+ describe("displayReadonlyWorkflow", () => {
+ it("snapshots the current workflow, disables persist+undo, then reloads readonly", () => {
+ const live = buildWorkflow({ name: "live" });
+ const incoming = buildWorkflow({ name: "incoming" });
+ actionSpy.getWorkflow.mockReturnValue(live);
+
+ service.displayReadonlyWorkflow(incoming);
+
+ expect(actionSpy.setTempWorkflow).toHaveBeenCalledWith(live);
+ expect(persistSpy.setWorkflowPersistFlag).toHaveBeenCalledWith(false);
+ expect(undoRedoSpy.disableWorkFlowModification).toHaveBeenCalled();
+ expect(actionSpy.reloadWorkflow).toHaveBeenCalledWith(incoming);
+ expect(actionSpy.disableWorkflowModification).toHaveBeenCalled();
+ });
+
+ it("only captures the modification state once across nested calls", () => {
+ actionSpy.checkWorkflowModificationEnabled.mockReturnValue(true);
+ service.displayReadonlyWorkflow(buildWorkflow());
+
+ // Second entry must not overwrite the snapshot to `false`.
+ actionSpy.checkWorkflowModificationEnabled.mockReturnValue(false);
+ service.displayReadonlyWorkflow(buildWorkflow());
+
+ expect(service.canRestoreVersion).toBe(true);
+ });
+ });
+
+ // ─── displayParticularVersion ─────────────────────────────────────────────
+
+ it("displayParticularVersion diffs, swaps the paper, and emits the flag", () => {
+ const liveContent = buildContent({ operators: [buildOperator({ operatorID: "live" })] });
+ const versionContent = buildContent({ operators: [buildOperator({ operatorID: "old" })] });
+ actionSpy.getWorkflowContent.mockReturnValue(liveContent);
+ const versionWorkflow = buildWorkflow({ content: versionContent });
+
+ service.displayParticularVersion(versionWorkflow, 5, 6);
+
+ expect(actionSpy.reloadWorkflow).toHaveBeenCalledWith(versionWorkflow);
+ expect(service.selectedVersionId.getValue()).toBe(5);
+ expect(service.selectedDisplayedVersionId.getValue()).toBe(6);
+ });
+
+ // ─── highlightOpBoundary / highlightOpBracket ─────────────────────────────
+
+ describe("highlight helpers", () => {
+ it("highlightOpBoundary writes rect.boundary/fill with the rgba color", () => {
+ service.highlightOpBoundary("op-1", "1,2,3,0.5");
+ expect(paperGetModelById).toHaveBeenCalledWith("op-1");
+ expect(modelAttr).toHaveBeenCalledWith("rect.boundary/fill", "rgba(1,2,3,0.5)");
});
- it("should return false when modificationEnabledBeforeTempWorkflow is undefined", () => {
- // Arrange
- service["modificationEnabledBeforeTempWorkflow"] = undefined;
+ it("highlightOpBracket writes the position-prefixed stroke attribute", () => {
+ service.highlightOpBracket("op-1", "1,2,3,0.5", "left-");
+ expect(modelAttr).toHaveBeenCalledWith("path.left-boundary/stroke", "rgba(1,2,3,0.5)");
+ });
+
+ it("is a no-op when the joint paper is not yet bound", () => {
+ getMainJointPaper.mockReturnValue(undefined);
+ service.highlightOpBoundary("op-1", "1,2,3,0.5");
+ expect(paperGetModelById).not.toHaveBeenCalled();
+ });
+ });
+
+ // ─── highlightOpVersionDiff ───────────────────────────────────────────────
+
+ describe("highlightOpVersionDiff", () => {
+ it("colors modified ops orange and added ops green", () => {
+ service.highlightOpVersionDiff({ modified: ["m"], added: ["a"], deleted: [] });
- // Act
- const result = service.canRestoreVersion;
+ expect(modelAttr).toHaveBeenCalledWith("rect.boundary/fill", "rgba(255,118,20,0.5)");
+ expect(modelAttr).toHaveBeenCalledWith("rect.boundary/fill", "rgba(0,255,0,0.5)");
+ });
+
+ it("draws left/right red brackets around the neighbors of deleted ops", () => {
+ const tempWorkflow = buildWorkflow({
+ content: buildContent({
+ operators: [buildOperator({ operatorID: "alive-left" }), buildOperator({ operatorID: "alive-right" })],
+ links: [buildLink("dead", "alive-right"), buildLink("alive-left", "dead")],
+ }),
+ });
+ actionSpy.getTempWorkflow.mockReturnValue(tempWorkflow);
+
+ service.highlightOpVersionDiff({ modified: [], added: [], deleted: ["dead"] });
+
+ expect(modelAttr).toHaveBeenCalledWith("path.left-boundary/stroke", "rgba(255,0,0,0.5)");
+ expect(modelAttr).toHaveBeenCalledWith("path.right-boundary/stroke", "rgba(255,0,0,0.5)");
+ });
+
+ it("skips bracket drawing when the temp workflow is missing", () => {
+ actionSpy.getTempWorkflow.mockReturnValue(undefined);
+
+ service.highlightOpVersionDiff({ modified: [], added: [], deleted: ["dead"] });
+
+ expect(getMainJointPaper).not.toHaveBeenCalled();
+ });
+ });
+
+ // ─── getWorkflowsDifference / getOperatorsDifference ──────────────────────
+
+ describe("getWorkflowsDifference", () => {
+ it("classifies operators into added, deleted, and modified", () => {
+ // Forward diff semantics: ids present in arg2 but not arg1 are "added"
+ // (to go from arg1 to arg2 you'd add them), ids in arg1 only are
+ // "deleted", and ids in both with differing content are "modified".
+ const a1 = buildOperator({ operatorID: "stay", operatorProperties: { a: 1 } });
+ const a2 = buildOperator({ operatorID: "stay", operatorProperties: { a: 2 } });
+ const onlyInArg1 = buildOperator({ operatorID: "in-arg1-only" });
+ const onlyInArg2 = buildOperator({ operatorID: "in-arg2-only" });
+
+ const diff = service.getWorkflowsDifference(
+ buildContent({ operators: [a1, onlyInArg1] }),
+ buildContent({ operators: [a2, onlyInArg2] })
+ );
+
+ expect(diff.added.sort()).toEqual(["in-arg2-only"]);
+ expect(diff.modified.sort()).toEqual(["stay"]);
+ expect(diff.deleted.sort()).toEqual(["in-arg1-only"]);
+ });
+
+ it("returns empty diffs when the contents are identical", () => {
+ const op = buildOperator();
+ const diff = service.getWorkflowsDifference(buildContent({ operators: [op] }), buildContent({ operators: [op] }));
+ expect(diff).toEqual({ added: [], modified: [], deleted: [] });
+ expect(service.operatorPropertyDiff).toEqual({});
+ });
+
+ it("records per-property and version-bump diffs in operatorPropertyDiff", () => {
+ const live = buildOperator({
+ operatorID: "x",
+ operatorVersion: "v2",
+ operatorProperties: { foo: "a", bar: 1 },
+ });
+ const old = buildOperator({
+ operatorID: "x",
+ operatorVersion: "v1",
+ operatorProperties: { foo: "b", bar: 1 },
+ });
+
+ service.getWorkflowsDifference(buildContent({ operators: [live] }), buildContent({ operators: [old] }));
+
+ const diffMap = service.operatorPropertyDiff["x"];
+ expect(diffMap.get("foo" as unknown as String)).toContain("rgb(255, 118, 20)");
+ expect(diffMap.has("bar" as unknown as String)).toBe(false);
+ expect(diffMap.has("operatorVersion" as unknown as String)).toBe(true);
+ });
+ });
+
+ // ─── revertToVersion / closeReadonlyWorkflowDisplay / closeParticular ─────
+
+ describe("close + revert helpers", () => {
+ it("revertToVersion clears stacks, re-enables modification, and exits readonly mode", () => {
+ service.setDisplayParticularVersion(true, 4, 8);
+ service["differentOpIDsList"] = { modified: ["m"], added: ["a"], deleted: [] };
+
+ service.revertToVersion();
+
+ expect(undoRedoSpy.clearRedoStack).toHaveBeenCalled();
+ expect(undoRedoSpy.clearUndoStack).toHaveBeenCalled();
+ expect(actionSpy.enableWorkflowModification).toHaveBeenCalled();
+ expect(actionSpy.resetTempWorkflow).toHaveBeenCalled();
+ expect(persistSpy.setWorkflowPersistFlag).toHaveBeenCalledWith(true);
+ expect(service.selectedVersionId.getValue()).toBeNull();
+ expect(modelAttr).toHaveBeenCalledWith("rect.boundary/fill", "rgba(0,0,0,0)");
+ });
+
+ it("closeReadonlyWorkflowDisplay restores the previous workflow and re-enables undo", () => {
+ const previous = buildWorkflow({ name: "previous" });
+ actionSpy.getTempWorkflow.mockReturnValue(previous);
+
+ service.closeReadonlyWorkflowDisplay();
+
+ expect(actionSpy.enableWorkflowModification).toHaveBeenCalled();
+ expect(undoRedoSpy.disableWorkFlowModification).toHaveBeenCalled();
+ expect(actionSpy.reloadWorkflow).toHaveBeenCalledWith(previous);
+ expect(actionSpy.resetTempWorkflow).toHaveBeenCalled();
+ expect(undoRedoSpy.enableWorkFlowModification).toHaveBeenCalled();
+ expect(persistSpy.setWorkflowPersistFlag).toHaveBeenCalledWith(true);
+ });
+
+ it("closeParticularVersionDisplay unhighlights, restores, and flips the flag", () => {
+ service["differentOpIDsList"] = { modified: ["m"], added: [], deleted: [] };
+ service.setDisplayParticularVersion(true, 1, 2);
+
+ service.closeParticularVersionDisplay();
+
+ expect(modelAttr).toHaveBeenCalledWith("rect.boundary/fill", "rgba(0,0,0,0)");
+ expect(actionSpy.reloadWorkflow).toHaveBeenCalled();
+ expect(service.selectedVersionId.getValue()).toBeNull();
+ });
+
+ it("restoreModificationState re-disables modification when the snapshot was false", () => {
+ actionSpy.checkWorkflowModificationEnabled.mockReturnValue(false);
+ service.displayReadonlyWorkflow(buildWorkflow());
+ actionSpy.disableWorkflowModification.mockClear();
+
+ service.closeReadonlyWorkflowDisplay();
+
+ expect(actionSpy.disableWorkflowModification).toHaveBeenCalled();
+ actionSpy.disableWorkflowModification.mockClear();
+ service.closeReadonlyWorkflowDisplay();
+ expect(actionSpy.disableWorkflowModification).not.toHaveBeenCalled();
+ });
+ });
+
+ // ─── HTTP endpoints ───────────────────────────────────────────────────────
+
+ describe("HTTP", () => {
+ it("retrieveVersionsOfWorkflow GETs /version/{wid}", async () => {
+ const entries: WorkflowVersionEntry[] = [{ vId: 1, creationTime: 0, content: "{}", importance: false }];
+ const pending = firstValueFrom(service.retrieveVersionsOfWorkflow(42));
+ const req = http.expectOne(`${API}/${WORKFLOW_VERSIONS_API_BASE_URL}/42`);
+ expect(req.request.method).toBe("GET");
+ req.flush(entries);
+ expect(await pending).toEqual(entries);
+ });
+
+ it("retrieveWorkflowByVersion parses a string `content` into an object", async () => {
+ const pending = firstValueFrom(service.retrieveWorkflowByVersion(42, 7));
+ const req = http.expectOne(`${API}/${WORKFLOW_VERSIONS_API_BASE_URL}/42/7`);
+ expect(req.request.method).toBe("GET");
+ req.flush({
+ ...buildWorkflow(),
+ content:
+ '{"operators":[],"operatorPositions":{},"links":[],"commentBoxes":[],"settings":{"dataTransferBatchSize":400,"executionMode":"PIPELINED"}}',
+ });
+ const result = await pending;
+ expect(typeof result.content).toBe("object");
+ expect(result.content.operators).toEqual([]);
+ });
+
+ it("retrieveWorkflowByVersion drops null payloads (filter blocks the emission)", () => {
+ let nextCalled = false;
+ let completed = false;
+ service.retrieveWorkflowByVersion(42, 7).subscribe({
+ next: () => (nextCalled = true),
+ complete: () => (completed = true),
+ });
+ http.expectOne(`${API}/${WORKFLOW_VERSIONS_API_BASE_URL}/42/7`).flush(null);
+ expect(nextCalled).toBe(false);
+ expect(completed).toBe(true);
+ });
- // Assert
- expect(result).toBe(false);
+ it("cloneWorkflowVersion POSTs to /version/clone/{vid} with the displayed id", async () => {
+ service.setDisplayParticularVersion(true, 11, 12);
+ const pending = firstValueFrom(service.cloneWorkflowVersion());
+ const req = http.expectOne(`${API}/${WORKFLOW_VERSIONS_API_BASE_URL}/clone/11`);
+ expect(req.request.method).toBe("POST");
+ expect(req.request.body).toEqual({ displayedVersionId: 12 });
+ req.flush(99);
+ expect(await pending).toBe(99);
});
});
});