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-5203-2153c17879ca4504da09d4d7dc9648d17f364aa0
in repository https://gitbox.apache.org/repos/asf/texera.git

commit 767219abe30851cd5754dd10b93d467c9c90e4ec
Author: Yicong Huang <[email protected]>
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) <[email protected]>
---
 .../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: "[email protected]",
+    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("[email protected]", 
"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("[email protected]");
+    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("[email protected]", 
"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("[email protected]", "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("[email protected]", "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: "[email protected]",
+    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);
     });
   });
 });

Reply via email to