This is an automated email from the ASF dual-hosted git repository.
aglinxinyuan pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git
The following commit(s) were added to refs/heads/main by this push:
new dcb457e053 test(frontend): expand spec coverage for download /
code-editor / annotation-suggestion (#5004)
dcb457e053 is described below
commit dcb457e0531272187c97a555370ea9834734c930
Author: Xinyuan Lin <[email protected]>
AuthorDate: Mon May 11 20:54:12 2026 -0700
test(frontend): expand spec coverage for download / code-editor /
annotation-suggestion (#5004)
### What changes were proposed in this PR?
Three areas were under-tested in the frontend spec suite; this PR
addresses all three as one cohesive change:
- **`DownloadService`** — went from 8 active specs (all `it.skip(...)`)
to 14 active specs + 1 still-skipped (`createZip`, blocked by an
unrelated `import * as JSZip` interop bug). New coverage:
`downloadSingleFile` (incl. default-filename fallback +
`isLogin=false`), `downloadDataset`, `downloadDatasetVersion`,
`downloadWorkflow` (JSON shape), `downloadWorkflowsAsZip`, single-file
`downloadOperatorsResult`, the `No files to download` error path,
`getWorkflowResultDownloadability` HTTP wiring, plus matching
error-notification paths.
- **`CodeEditorComponent`** — went from 1 spec (`should create`) to 9
specs: operator-type → language detection for the three V2 Python
operator types and for plain Java / unknown types; the `languageTitle`
formula; `getCoeditorCursorStyles` for hex and rgba colours.
- **`AnnotationSuggestionComponent`** — new spec file (6 specs):
creation, default inputs, accept / decline event emission, independence
of the two emitters, `@Input` binding.
#### Why one test stays skipped
The multi-file `downloadOperatorsResult` path goes through `new
JSZip()`, where the production code does `import * as JSZip from
"jszip"`. The CI build flags this as `Constructing "JSZip" will crash at
run-time because it's an import namespace object`, and vitest reproduces
it as `__vite_ssr_import_* is not a constructor`. The spec is left as
`it.skip(...)` with a comment so it gets re-enabled when the source
switches to a default import.
### Any related issues, documentation, discussions?
Closes #5003.
### How was this PR tested?
Ran the suite on both branches under active development — confirms the
new specs are stable AND the in-flight v10 refactor on #4997 preserves
the externally-observable behaviour these specs exercise.
| Branch | Files | Tests |
|---|---|---|
| `main` | 66 passed / 2 skipped | 333 passed / 4 skipped / 2 todo |
| `chore/monaco-lsp-v10` (#4997) | 65 passed / 2 skipped | 297 passed /
4 skipped / 2 todo |
```
yarn test # on main → 333 passed / 4 skipped / 2 todo
yarn test # on chore/monaco-lsp-v10 → 297 passed / 4 skipped / 2 todo
```
The test-count delta between branches is unrelated PR drift (`main` has
picked up extra non-monaco specs that haven't been merged into the v10
branch yet); the spec files this PR adds run green on both.
### Was this PR authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Opus 4.7)
---
.../service/user/download/download.service.spec.ts | 365 +++++++++++----------
.../annotation-suggestion.component.spec.ts | 87 +++++
.../code-editor.component.spec.ts | 158 ++++++++-
3 files changed, 422 insertions(+), 188 deletions(-)
diff --git
a/frontend/src/app/dashboard/service/user/download/download.service.spec.ts
b/frontend/src/app/dashboard/service/user/download/download.service.spec.ts
index 4a9e17729a..d0588665c1 100644
--- a/frontend/src/app/dashboard/service/user/download/download.service.spec.ts
+++ b/frontend/src/app/dashboard/service/user/download/download.service.spec.ts
@@ -16,34 +16,31 @@
* specific language governing permissions and limitations
* under the License.
*/
-// TODO: rewrite skipped tests away from Jasmine done/fail callbacks (#4861).
-// These stubs make the it.skip bodies type-check without running.
-declare function done(): void;
-declare function fail(message?: string): never;
-
-// TODO(vitest): done callbacks need rewrite to async/Promise pattern; these
specs are skipped pending follow-up — tracked in #4861.
import { TestBed } from "@angular/core/testing";
-import { HttpClientTestingModule } from "@angular/common/http/testing";
+import { HttpClientTestingModule, HttpTestingController } from
"@angular/common/http/testing";
import { DownloadService } from "./download.service";
import { DatasetService } from "../dataset/dataset.service";
import { FileSaverService } from "../file/file-saver.service";
import { NotificationService } from
"../../../../common/service/notification/notification.service";
import { WorkflowPersistService } from
"../../../../common/service/workflow-persist/workflow-persist.service";
-import { of, throwError } from "rxjs";
+import { firstValueFrom, lastValueFrom, of, throwError } from "rxjs";
import { commonTestProviders } from "../../../../common/testing/test-utils";
import type { Mocked } from "vitest";
+
describe("DownloadService", () => {
let downloadService: DownloadService;
let datasetServiceSpy: Mocked<DatasetService>;
let fileSaverServiceSpy: Mocked<FileSaverService>;
let notificationServiceSpy: Mocked<NotificationService>;
+ let workflowPersistServiceSpy: Mocked<WorkflowPersistService>;
+ let httpMock: HttpTestingController;
beforeEach(() => {
const datasetSpy = { retrieveDatasetVersionSingleFile: vi.fn(),
retrieveDatasetVersionZip: vi.fn() };
const fileSaverSpy = { saveAs: vi.fn() };
const notificationSpy = { info: vi.fn(), success: vi.fn(), error: vi.fn()
};
- const workflowPersistSpy = { getWorkflow: vi.fn() };
+ const workflowPersistSpy = { retrieveWorkflow: vi.fn() };
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
@@ -61,198 +58,224 @@ describe("DownloadService", () => {
datasetServiceSpy = TestBed.inject(DatasetService) as unknown as
Mocked<DatasetService>;
fileSaverServiceSpy = TestBed.inject(FileSaverService) as unknown as
Mocked<FileSaverService>;
notificationServiceSpy = TestBed.inject(NotificationService) as unknown as
Mocked<NotificationService>;
+ workflowPersistServiceSpy = TestBed.inject(WorkflowPersistService) as
unknown as Mocked<WorkflowPersistService>;
+ httpMock = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => {
+ // Catch any test that fires an HTTP request without flushing it; keeps
+ // the suite safe as more specs start using HttpTestingController.
+ httpMock.verify();
});
- it.skip("should download a single file successfully", () => {
- const filePath = "test/file.txt";
+ // ─── downloadSingleFile ───────────────────────────────────────────────────
+
+ it("downloads a single file and saves it under the basename of the path",
async () => {
const mockBlob = new Blob(["test content"], { type: "text/plain" });
+
datasetServiceSpy.retrieveDatasetVersionSingleFile.mockReturnValue(of(mockBlob));
+ const result = await
firstValueFrom(downloadService.downloadSingleFile("test/file.txt", true));
+
+ expect(result).toBe(mockBlob);
+ expect(notificationServiceSpy.info).toHaveBeenCalledWith("Starting to
download file test/file.txt");
+
expect(datasetServiceSpy.retrieveDatasetVersionSingleFile).toHaveBeenCalledWith("test/file.txt",
true);
+ expect(fileSaverServiceSpy.saveAs).toHaveBeenCalledWith(mockBlob,
"file.txt");
+ expect(notificationServiceSpy.success).toHaveBeenCalledWith("File
test/file.txt has been downloaded");
+ });
+
+ it("falls back to a default filename when the path has no basename segment",
async () => {
+ const mockBlob = new Blob(["x"], { type: "text/plain" });
datasetServiceSpy.retrieveDatasetVersionSingleFile.mockReturnValue(of(mockBlob));
- downloadService.downloadSingleFile(filePath, true).subscribe({
- next: blob => {
- expect(blob).toBe(mockBlob);
- expect(notificationServiceSpy.info).toHaveBeenCalledWith("Starting to
download file test/file.txt");
-
expect(datasetServiceSpy.retrieveDatasetVersionSingleFile).toHaveBeenCalledWith(filePath,
true);
- expect(fileSaverServiceSpy.saveAs).toHaveBeenCalledWith(mockBlob,
"file.txt");
- expect(notificationServiceSpy.success).toHaveBeenCalledWith("File
test/file.txt has been downloaded");
- done();
- },
- error: (error: unknown) => {
- fail("Should not have thrown an error: " + error);
- },
- });
+ await firstValueFrom(downloadService.downloadSingleFile("", true));
+
+ // path.split("/").pop() returns "" for "", which falls through to the
default name
+ expect(fileSaverServiceSpy.saveAs).toHaveBeenCalledWith(mockBlob,
"download");
});
- it.skip("should handle download failure correctly", () => {
- const filePath = "test/file.txt";
- const errorMessage = "Download failed";
-
-
datasetServiceSpy.retrieveDatasetVersionSingleFile.mockReturnValue(throwError(()
=> new Error(errorMessage)));
-
- downloadService.downloadSingleFile(filePath, true).subscribe({
- next: () => {
- fail("Should have thrown an error");
- },
- error: (error: unknown) => {
- expect(error).toBeTruthy();
- expect(notificationServiceSpy.info).toHaveBeenCalledWith("Starting to
download file test/file.txt");
-
expect(datasetServiceSpy.retrieveDatasetVersionSingleFile).toHaveBeenCalledWith(filePath,
true);
- expect(fileSaverServiceSpy.saveAs).not.toHaveBeenCalled();
- expect(notificationServiceSpy.error).toHaveBeenCalledWith("Error
downloading file 'test/file.txt'");
- done();
- },
- });
+ it("propagates errors from downloadSingleFile and emits the error
notification", async () => {
+
datasetServiceSpy.retrieveDatasetVersionSingleFile.mockReturnValue(throwError(()
=> new Error("boom")));
+
+ await
expect(firstValueFrom(downloadService.downloadSingleFile("test/file.txt",
true))).rejects.toThrow("boom");
+
+ expect(notificationServiceSpy.info).toHaveBeenCalledWith("Starting to
download file test/file.txt");
+ expect(fileSaverServiceSpy.saveAs).not.toHaveBeenCalled();
+ expect(notificationServiceSpy.error).toHaveBeenCalledWith("Error
downloading file 'test/file.txt'");
});
- it.skip("should download a dataset successfully", () => {
- const datasetId = 1;
- const datasetName = "TestDataset";
- const mockBlob = new Blob(["dataset content"], { type: "application/zip"
});
+ it("passes isLogin=false through to retrieveDatasetVersionSingleFile", async
() => {
+ const mockBlob = new Blob(["x"], { type: "text/plain" });
+
datasetServiceSpy.retrieveDatasetVersionSingleFile.mockReturnValue(of(mockBlob));
+
+ await
firstValueFrom(downloadService.downloadSingleFile("public/sample.csv", false));
+
+
expect(datasetServiceSpy.retrieveDatasetVersionSingleFile).toHaveBeenCalledWith("public/sample.csv",
false);
+ });
+ // ─── downloadDataset ──────────────────────────────────────────────────────
+
+ it("downloads the latest dataset version as a zip named after the dataset",
async () => {
+ const mockBlob = new Blob(["dataset content"], { type: "application/zip"
});
datasetServiceSpy.retrieveDatasetVersionZip.mockReturnValue(of(mockBlob));
- downloadService.downloadDataset(datasetId, datasetName).subscribe({
- next: blob => {
- expect(blob).toBe(mockBlob);
- expect(notificationServiceSpy.info).toHaveBeenCalledWith(
- "Starting to download the latest version of the dataset as ZIP"
- );
-
expect(datasetServiceSpy.retrieveDatasetVersionZip).toHaveBeenCalledWith(datasetId);
- expect(fileSaverServiceSpy.saveAs).toHaveBeenCalledWith(mockBlob,
"TestDataset.zip");
- expect(notificationServiceSpy.success).toHaveBeenCalledWith(
- "The latest version of the dataset has been downloaded as ZIP"
- );
- done();
- },
- error: (error: unknown) => {
- fail("Should not have thrown an error");
- },
- });
+ const result = await firstValueFrom(downloadService.downloadDataset(1,
"TestDataset"));
+
+ expect(result).toBe(mockBlob);
+ expect(notificationServiceSpy.info).toHaveBeenCalledWith(
+ "Starting to download the latest version of the dataset as ZIP"
+ );
+
expect(datasetServiceSpy.retrieveDatasetVersionZip).toHaveBeenCalledWith(1);
+ expect(fileSaverServiceSpy.saveAs).toHaveBeenCalledWith(mockBlob,
"TestDataset.zip");
+ expect(notificationServiceSpy.success).toHaveBeenCalledWith(
+ "The latest version of the dataset has been downloaded as ZIP"
+ );
});
- it.skip("should handle dataset download failure correctly", () => {
- const datasetId = 1;
- const datasetName = "TestDataset";
- const errorMessage = "Dataset download failed";
-
- datasetServiceSpy.retrieveDatasetVersionZip.mockReturnValue(throwError(()
=> new Error(errorMessage)));
-
- downloadService.downloadDataset(datasetId, datasetName).subscribe({
- next: () => {
- fail("Should have thrown an error");
- },
- error: (error: unknown) => {
- expect(error).toBeTruthy();
- expect(notificationServiceSpy.info).toHaveBeenCalledWith(
- "Starting to download the latest version of the dataset as ZIP"
- );
-
expect(datasetServiceSpy.retrieveDatasetVersionZip).toHaveBeenCalledWith(datasetId);
- expect(fileSaverServiceSpy.saveAs).not.toHaveBeenCalled();
- expect(notificationServiceSpy.error).toHaveBeenCalledWith(
- "Error downloading the latest version of the dataset as ZIP"
- );
- done();
- },
- });
+ it("emits the dataset error notification and rethrows on retrieve failure",
async () => {
+ datasetServiceSpy.retrieveDatasetVersionZip.mockReturnValue(throwError(()
=> new Error("fail")));
+
+ await expect(firstValueFrom(downloadService.downloadDataset(1,
"TestDataset"))).rejects.toThrow("fail");
+
+ expect(fileSaverServiceSpy.saveAs).not.toHaveBeenCalled();
+ expect(notificationServiceSpy.error).toHaveBeenCalledWith(
+ "Error downloading the latest version of the dataset as ZIP"
+ );
});
- it.skip("should download a dataset version successfully", () => {
- const datasetId = 1;
- const datasetVersionId = 1;
- const datasetName = "TestDataset";
- const versionName = "v1.0";
- const mockBlob = new Blob(["version content"], { type: "application/zip"
});
+ // ─── downloadDatasetVersion ───────────────────────────────────────────────
+ it("downloads a specific dataset version with composite zip name", async ()
=> {
+ const mockBlob = new Blob(["v1"], { type: "application/zip" });
datasetServiceSpy.retrieveDatasetVersionZip.mockReturnValue(of(mockBlob));
- downloadService.downloadDatasetVersion(datasetId, datasetVersionId,
datasetName, versionName).subscribe({
- next: blob => {
- expect(blob).toBe(mockBlob);
- expect(notificationServiceSpy.info).toHaveBeenCalledWith("Starting to
download version v1.0 as ZIP");
-
expect(datasetServiceSpy.retrieveDatasetVersionZip).toHaveBeenCalledWith(datasetId,
datasetVersionId);
- expect(fileSaverServiceSpy.saveAs).toHaveBeenCalledWith(mockBlob,
"TestDataset-v1.0.zip");
- expect(notificationServiceSpy.success).toHaveBeenCalledWith("Version
v1.0 has been downloaded as ZIP");
- done();
- },
- error: (error: unknown) => {
- fail("Should not have thrown an error");
- },
- });
+ const result = await
firstValueFrom(downloadService.downloadDatasetVersion(1, 2, "TestDataset",
"v1.0"));
+
+ expect(result).toBe(mockBlob);
+ expect(notificationServiceSpy.info).toHaveBeenCalledWith("Starting to
download version v1.0 as ZIP");
+
expect(datasetServiceSpy.retrieveDatasetVersionZip).toHaveBeenCalledWith(1, 2);
+ expect(fileSaverServiceSpy.saveAs).toHaveBeenCalledWith(mockBlob,
"TestDataset-v1.0.zip");
+ expect(notificationServiceSpy.success).toHaveBeenCalledWith("Version v1.0
has been downloaded as ZIP");
});
- it.skip("should handle dataset version download failure correctly", () => {
- const datasetId = 1;
- const datasetVersionId = 1;
- const datasetName = "TestDataset";
- const versionName = "v1.0";
- const errorMessage = "Dataset version download failed";
-
- datasetServiceSpy.retrieveDatasetVersionZip.mockReturnValue(throwError(()
=> new Error(errorMessage)));
-
- downloadService.downloadDatasetVersion(datasetId, datasetVersionId,
datasetName, versionName).subscribe({
- next: () => {
- fail("Should have thrown an error");
- },
- error: (error: unknown) => {
- expect(error).toBeTruthy();
- expect(notificationServiceSpy.info).toHaveBeenCalledWith("Starting to
download version v1.0 as ZIP");
-
expect(datasetServiceSpy.retrieveDatasetVersionZip).toHaveBeenCalledWith(datasetId,
datasetVersionId);
- expect(fileSaverServiceSpy.saveAs).not.toHaveBeenCalled();
- expect(notificationServiceSpy.error).toHaveBeenCalledWith("Error
downloading version 'v1.0' as ZIP");
- done();
- },
- });
+ it("emits the version-specific error notification on retrieve failure",
async () => {
+ datasetServiceSpy.retrieveDatasetVersionZip.mockReturnValue(throwError(()
=> new Error("nope")));
+
+ await expect(firstValueFrom(downloadService.downloadDatasetVersion(1, 2,
"TestDataset", "v1.0"))).rejects.toThrow(
+ "nope"
+ );
+
+ expect(notificationServiceSpy.error).toHaveBeenCalledWith("Error
downloading version 'v1.0' as ZIP");
});
- it.skip("should download workflows as ZIP successfully", () => {
- const workflowEntries = [
- { id: 1, name: "Workflow1" },
- { id: 2, name: "Workflow2" },
- ];
- const mockBlob = new Blob(["zip content"], { type: "application/zip" });
+ // ─── downloadWorkflow ─────────────────────────────────────────────────────
- vi.spyOn(downloadService as any,
"createWorkflowsZip").mockReturnValue(of(mockBlob));
+ it("downloads a workflow as a JSON blob named after the workflow", async ()
=> {
+ const workflowContent = { hello: "world", operators: [] };
+ workflowPersistServiceSpy.retrieveWorkflow.mockReturnValue(of({ content:
workflowContent } as any));
- downloadService.downloadWorkflowsAsZip(workflowEntries).subscribe({
- next: blob => {
- expect(blob).toBe(mockBlob);
- expect(notificationServiceSpy.info).toHaveBeenCalledWith("Starting to
download workflows as ZIP");
- expect((downloadService as
any).createWorkflowsZip).toHaveBeenCalledWith(workflowEntries);
- expect(fileSaverServiceSpy.saveAs).toHaveBeenCalledWith(
- mockBlob,
- expect.stringMatching(/^workflowExports-.*\.zip$/)
- );
- expect(notificationServiceSpy.success).toHaveBeenCalledWith("Workflows
have been downloaded as ZIP");
- done();
- },
- error: (error: unknown) => {
- fail("Should not have thrown an error");
- },
- });
+ const result = await firstValueFrom(downloadService.downloadWorkflow(42,
"MyWorkflow"));
+
+ expect(result.fileName).toBe("MyWorkflow.json");
+ expect(result.blob).toBeInstanceOf(Blob);
+ expect(result.blob.type).toBe("text/plain;charset=utf-8");
+ expect(fileSaverServiceSpy.saveAs).toHaveBeenCalledWith(result.blob,
"MyWorkflow.json");
+ // Blob.text() isn't shipped by jsdom, so we don't pin the body content
+ // here; the saveAs assertion above already verifies the path that
+ // produced it.
});
- it.skip("should handle workflows ZIP download failure correctly", () => {
- const workflowEntries = [
+ // ─── downloadWorkflowsAsZip ───────────────────────────────────────────────
+
+ it("downloads the workflow ZIP and routes through createWorkflowsZip", async
() => {
+ const mockBlob = new Blob(["zip"], { type: "application/zip" });
+ const entries = [
{ id: 1, name: "Workflow1" },
{ id: 2, name: "Workflow2" },
];
- const errorMessage = "Workflows ZIP download failed";
-
- vi.spyOn(downloadService as any,
"createWorkflowsZip").mockReturnValue(throwError(() => new
Error(errorMessage)));
-
- downloadService.downloadWorkflowsAsZip(workflowEntries).subscribe({
- next: () => {
- fail("Should have thrown an error");
- },
- error: (error: unknown) => {
- expect(error).toBeTruthy();
- expect(notificationServiceSpy.info).toHaveBeenCalledWith("Starting to
download workflows as ZIP");
- expect((downloadService as
any).createWorkflowsZip).toHaveBeenCalledWith(workflowEntries);
- expect(fileSaverServiceSpy.saveAs).not.toHaveBeenCalled();
- expect(notificationServiceSpy.error).toHaveBeenCalledWith("Error
downloading workflows as ZIP");
- done();
- },
- });
+ vi.spyOn(downloadService as any,
"createWorkflowsZip").mockReturnValue(of(mockBlob));
+
+ const result = await
firstValueFrom(downloadService.downloadWorkflowsAsZip(entries));
+
+ expect(result).toBe(mockBlob);
+ expect(notificationServiceSpy.info).toHaveBeenCalledWith("Starting to
download workflows as ZIP");
+ expect((downloadService as
any).createWorkflowsZip).toHaveBeenCalledWith(entries);
+ expect(fileSaverServiceSpy.saveAs).toHaveBeenCalledWith(
+ mockBlob,
+ expect.stringMatching(/^workflowExports-.*\.zip$/)
+ );
+ expect(notificationServiceSpy.success).toHaveBeenCalledWith("Workflows
have been downloaded as ZIP");
+ });
+
+ it("propagates errors from createWorkflowsZip with the expected error
notification", async () => {
+ vi.spyOn(downloadService as any,
"createWorkflowsZip").mockReturnValue(throwError(() => new Error("zip fail")));
+
+ await expect(firstValueFrom(downloadService.downloadWorkflowsAsZip([{ id:
1, name: "W" }]))).rejects.toThrow(
+ "zip fail"
+ );
+
+ expect(fileSaverServiceSpy.saveAs).not.toHaveBeenCalled();
+ expect(notificationServiceSpy.error).toHaveBeenCalledWith("Error
downloading workflows as ZIP");
+ });
+
+ // ─── downloadOperatorsResult ──────────────────────────────────────────────
+
+ it("downloads a single operator file directly when there's exactly one
file", async () => {
+ const fileBlob = new Blob(["hello"], { type: "text/plain" });
+ const result = await firstValueFrom(
+ downloadService.downloadOperatorsResult([of([{ filename: "out.csv",
blob: fileBlob }])], {
+ wid: 1,
+ name: "W",
+ } as any)
+ );
+
+ expect(result).toBe(fileBlob);
+ expect(notificationServiceSpy.info).toHaveBeenCalledWith("Starting to
download operator result");
+ expect(fileSaverServiceSpy.saveAs).toHaveBeenCalledWith(fileBlob,
"out.csv");
+ expect(notificationServiceSpy.success).toHaveBeenCalledWith("Operator
result has been downloaded");
+ });
+
+ // The multi-file zip path goes through `new JSZip()` against the
+ // `import * as JSZip from "jszip"` namespace, which the build flags as
+ // `Constructing "JSZip" will crash at run-time because it's an import
+ // namespace object`. Vitest reproduces the failure (`__vite_ssr_import_*
+ // is not a constructor`). Tracked as a separate cleanup in the codebase;
+ // the test is here as a placeholder so we re-enable it once the import
+ // is normalised to a default import.
+ it.skip("zips multiple operator files into a workflow-named archive", async
() => {
+ const a = new Blob(["a"], { type: "text/plain" });
+ const b = new Blob(["b"], { type: "text/plain" });
+ const result = await firstValueFrom(
+ downloadService.downloadOperatorsResult(
+ [
+ of([
+ { filename: "a.csv", blob: a },
+ { filename: "b.csv", blob: b },
+ ]),
+ ],
+ { wid: 7, name: "TwoFile" } as any
+ )
+ );
+
+ expect(result).toBeInstanceOf(Blob);
+ expect(fileSaverServiceSpy.saveAs).toHaveBeenCalledWith(expect.any(Blob),
"results_7_TwoFile.zip");
+ expect(notificationServiceSpy.success).toHaveBeenCalledWith("Operator
results have been downloaded as ZIP");
+ });
+
+ it("errors out cleanly when no operator result files are provided", async ()
=> {
+ await expect(
+ firstValueFrom(downloadService.downloadOperatorsResult([of([])], { wid:
1, name: "Empty" } as any))
+ ).rejects.toThrow("No files to download");
+ });
+
+ // ─── getWorkflowResultDownloadability ─────────────────────────────────────
+
+ it("hits the downloadability endpoint and returns the operator → labels
map", async () => {
+ const promise =
lastValueFrom(downloadService.getWorkflowResultDownloadability(99));
+ const req = httpMock.expectOne(r =>
r.url.includes("/99/result/downloadability"));
+ expect(req.request.method).toBe("GET");
+ req.flush({ "op-1": ["my-dataset"], "op-2": [] });
+
+ const map = await promise;
+ expect(map).toEqual({ "op-1": ["my-dataset"], "op-2": [] });
});
});
diff --git
a/frontend/src/app/workspace/component/code-editor-dialog/annotation-suggestion.component.spec.ts
b/frontend/src/app/workspace/component/code-editor-dialog/annotation-suggestion.component.spec.ts
new file mode 100644
index 0000000000..cce6b27d4f
--- /dev/null
+++
b/frontend/src/app/workspace/component/code-editor-dialog/annotation-suggestion.component.spec.ts
@@ -0,0 +1,87 @@
+/**
+ * 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 { ComponentFixture, TestBed } from "@angular/core/testing";
+import { AnnotationSuggestionComponent } from
"./annotation-suggestion.component";
+
+describe("AnnotationSuggestionComponent", () => {
+ let component: AnnotationSuggestionComponent;
+ let fixture: ComponentFixture<AnnotationSuggestionComponent>;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [AnnotationSuggestionComponent],
+ }).compileComponents();
+ fixture = TestBed.createComponent(AnnotationSuggestionComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it("creates", () => {
+ expect(component).toBeTruthy();
+ });
+
+ it("defaults its inputs to empty / zero", () => {
+ expect(component.code).toBe("");
+ expect(component.suggestion).toBe("");
+ expect(component.top).toBe(0);
+ expect(component.left).toBe(0);
+ });
+
+ it("emits accept when onAccept is called", () => {
+ const spy = vi.fn();
+ component.accept.subscribe(spy);
+ component.onAccept();
+ expect(spy).toHaveBeenCalledTimes(1);
+ });
+
+ it("emits decline when onDecline is called", () => {
+ const spy = vi.fn();
+ component.decline.subscribe(spy);
+ component.onDecline();
+ expect(spy).toHaveBeenCalledTimes(1);
+ });
+
+ it("emits accept and decline independently — onAccept does not fire decline,
and vice versa", () => {
+ const acceptSpy = vi.fn();
+ const declineSpy = vi.fn();
+ component.accept.subscribe(acceptSpy);
+ component.decline.subscribe(declineSpy);
+
+ component.onAccept();
+ expect(acceptSpy).toHaveBeenCalledTimes(1);
+ expect(declineSpy).not.toHaveBeenCalled();
+
+ component.onDecline();
+ expect(acceptSpy).toHaveBeenCalledTimes(1);
+ expect(declineSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it("respects the latest values bound to its @Input fields", () => {
+ component.code = "x = 1";
+ component.suggestion = ": int";
+ component.top = 50;
+ component.left = 75;
+ fixture.detectChanges();
+ expect(component.code).toBe("x = 1");
+ expect(component.suggestion).toBe(": int");
+ expect(component.top).toBe(50);
+ expect(component.left).toBe(75);
+ });
+});
diff --git
a/frontend/src/app/workspace/component/code-editor-dialog/code-editor.component.spec.ts
b/frontend/src/app/workspace/component/code-editor-dialog/code-editor.component.spec.ts
index bb8dfcce98..b477ff5979 100644
---
a/frontend/src/app/workspace/component/code-editor-dialog/code-editor.component.spec.ts
+++
b/frontend/src/app/workspace/component/code-editor-dialog/code-editor.component.spec.ts
@@ -24,40 +24,164 @@ import { WorkflowActionService } from
"../../service/workflow-graph/model/workfl
import { mockJavaUDFPredicate, mockPoint } from
"../../service/workflow-graph/model/mock-workflow-data";
import { OperatorMetadataService } from
"../../service/operator-metadata/operator-metadata.service";
import { StubOperatorMetadataService } from
"../../service/operator-metadata/stub-operator-metadata.service";
+import { mockOperatorMetaData } from
"../../service/operator-metadata/mock-operator-metadata.data";
import { commonTestProviders } from "../../../common/testing/test-utils";
+import { OperatorPredicate } from "../../types/workflow-common.interface";
+import { OperatorSchema } from "../../types/operator-schema.interface";
+import { of } from "rxjs";
-describe("CodeEditorDialogComponent", () => {
- let component: CodeEditorComponent;
- let fixture: ComponentFixture<CodeEditorComponent>;
+// Operator types that the constructor's language-detection branch must map
+// to a specific language. `RUDFSource` / `RUDF` -> `r`; the three V2 Python
+// types -> `python`; everything else -> `java`. Local to this spec so we
+// don't perturb the shared mock-workflow-data fixtures.
+const R_OPERATOR_TYPES = ["RUDFSource", "RUDF"];
+const PYTHON_OPERATOR_TYPES = ["PythonUDFV2", "PythonUDFSourceV2",
"DualInputPortsPythonUDFV2"];
+
+// Augment `mockOperatorMetaData` with synthetic schemas for the V2 operator
+// types and one unknown type so `addOperator` and `JointUIService` accept
+// them. Cloning the existing `PythonUDF` schema and renaming the
+// `operatorType` is the cheapest way to satisfy both `operatorTypeExists`
+// and the schema-driven joint element creation.
+const baseSchema = mockOperatorMetaData.operators.find(op => op.operatorType
=== "PythonUDF");
+if (!baseSchema) {
+ throw new Error(
+ "CodeEditorComponent spec setup expected a PythonUDF schema in
mockOperatorMetaData — fixture has drifted."
+ );
+}
+const synthesizeSchema = (operatorType: string): OperatorSchema => ({
...baseSchema, operatorType });
+const augmentedSchemas: OperatorSchema[] = [
+ ...mockOperatorMetaData.operators,
+ ...PYTHON_OPERATOR_TYPES.map(synthesizeSchema),
+ ...R_OPERATOR_TYPES.map(synthesizeSchema),
+ synthesizeSchema("SomeUnknownType"),
+];
+class AugmentedStubMetadataService extends StubOperatorMetadataService {
+ // JointUIService snapshots `operatorSchemas` from this stream once on
+ // construction, so we have to feed it the augmented list (overriding only
+ // `getOperatorSchema`/`operatorTypeExists` is not enough).
+ private readonly augmentedMetadata = of({
+ ...mockOperatorMetaData,
+ operators: augmentedSchemas,
+ });
+ override getOperatorMetadata(): typeof this.augmentedMetadata {
+ return this.augmentedMetadata;
+ }
+ override getOperatorSchema(operatorType: string): OperatorSchema {
+ const schema = augmentedSchemas.find(op => op.operatorType ===
operatorType);
+ if (!schema) throw new Error(`unknown operatorType ${operatorType}`);
+ return schema;
+ }
+ override operatorTypeExists(operatorType: string): boolean {
+ return augmentedSchemas.some(op => op.operatorType === operatorType);
+ }
+}
+
+const buildPredicate = (operatorID: string, operatorType: string):
OperatorPredicate => ({
+ operatorID,
+ operatorType,
+ operatorVersion: "p1",
+ operatorProperties: {},
+ inputPorts: [{ portID: "input-0" }],
+ outputPorts: [{ portID: "output-0" }],
+ showAdvanced: false,
+ isDisabled: false,
+});
+
+describe("CodeEditorComponent", () => {
let workflowActionService: WorkflowActionService;
beforeEach(async () => {
await TestBed.configureTestingModule({
providers: [
WorkflowActionService,
- {
- provide: OperatorMetadataService,
- useClass: StubOperatorMetadataService,
- },
+ { provide: OperatorMetadataService, useClass:
AugmentedStubMetadataService },
...commonTestProviders,
],
imports: [CodeEditorComponent, HttpClientTestingModule],
}).compileComponents();
workflowActionService = TestBed.inject(WorkflowActionService);
- workflowActionService.addOperator(mockJavaUDFPredicate, mockPoint);
-
workflowActionService.getJointGraphWrapper().highlightOperators(mockJavaUDFPredicate.operatorID);
- fixture = TestBed.createComponent(CodeEditorComponent);
- component = fixture.componentInstance;
+ });
+
+ function makeFixture(predicate: OperatorPredicate):
ComponentFixture<CodeEditorComponent> {
+ workflowActionService.addOperator(predicate, mockPoint);
+
workflowActionService.getJointGraphWrapper().highlightOperators(predicate.operatorID);
+ const fixture = TestBed.createComponent(CodeEditorComponent);
fixture.detectChanges();
+ return fixture;
+ }
+
+ it("creates with the highlighted operator", () => {
+ const fixture = makeFixture(mockJavaUDFPredicate);
+ expect(fixture.componentInstance).toBeTruthy();
+
expect(fixture.componentInstance.currentOperatorId).toBe(mockJavaUDFPredicate.operatorID);
+ });
+
+ // Language detection — the constructor maps `RUDFSource` / `RUDF` to `r`,
+ // the three V2-era Python operator types to `python`, and anything else
+ // to `java`. The exact branch lives in the constructor; the public
+ // `language` field is what the rest of the editor (LSP wiring, file-
+ // suffix selection) keys off.
+
+ R_OPERATOR_TYPES.forEach((operatorType, index) => {
+ it(`picks language="r" for operatorType=${operatorType}`, () => {
+ const fixture = makeFixture(buildPredicate(`r-${index}`, operatorType));
+ expect(fixture.componentInstance.language).toBe("r");
+ expect(fixture.componentInstance.languageTitle).toBe("R UDF");
+ });
});
- it("should create", () => {
- expect(component).toBeTruthy();
+ PYTHON_OPERATOR_TYPES.forEach((operatorType, index) => {
+ it(`picks language="python" for operatorType=${operatorType}`, () => {
+ const fixture = makeFixture(buildPredicate(`p-${index}`, operatorType));
+ expect(fixture.componentInstance.language).toBe("python");
+ expect(fixture.componentInstance.languageTitle).toBe("Python UDF");
+ });
});
- // it("should create a websocket when the editor is opened", () => {
- // let socketInstance = component.getLanguageServerSocket();
- // expect(socketInstance).toBeTruthy();
- // });
+ it('picks language="java" for plain JavaUDF', () => {
+ const fixture = makeFixture(mockJavaUDFPredicate);
+ expect(fixture.componentInstance.language).toBe("java");
+ expect(fixture.componentInstance.languageTitle).toBe("Java UDF");
+ });
+
+ it('picks language="java" for unknown operator types', () => {
+ const fixture = makeFixture(buildPredicate("u-0", "SomeUnknownType"));
+ expect(fixture.componentInstance.language).toBe("java");
+ expect(fixture.componentInstance.languageTitle).toBe("Java UDF");
+ });
+
+ it("derives languageTitle as Capitalized(language) + ' UDF'", () => {
+ const fixture = makeFixture(buildPredicate("p-x", "PythonUDFV2"));
+ const c = fixture.componentInstance;
+ // Independent re-derivation matches whatever the component computed.
+ const expected = `${c.language[0].toUpperCase()}${c.language.slice(1)}
UDF`;
+ expect(c.languageTitle).toBe(expected);
+ });
+
+ // Coeditor cursor styles — getCoeditorCursorStyles takes the awareness-
+ // sourced clientId + colour and wraps a `<style>` block via
+ // `DomSanitizer.bypassSecurityTrustHtml`, so the return value is a
+ // SafeHtml (consumed via `[innerHTML]` in the template). We assert the
+ // wrapper shape (truthy DomSanitizer-wrapped object) for valid inputs.
+ // Exact CSS contents are sanitizer-internal and differ across builds, so
+ // we don't pin them here.
+
+ it("produces a SafeHtml for a coeditor with a numeric clientId and a hex
colour", () => {
+ const fixture = makeFixture(mockJavaUDFPredicate);
+ const result = fixture.componentInstance.getCoeditorCursorStyles({
+ clientId: "12345",
+ color: "#ff00aa",
+ } as any);
+ expect(result).toBeTruthy();
+ });
+
+ it("produces a SafeHtml for a coeditor with an rgba colour", () => {
+ const fixture = makeFixture(mockJavaUDFPredicate);
+ const result = fixture.componentInstance.getCoeditorCursorStyles({
+ clientId: "42",
+ color: "rgba(10, 20, 30, 0.8)",
+ } as any);
+ expect(result).toBeTruthy();
+ });
});