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 4a9e17729a8..d0588665c1a 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; let fileSaverServiceSpy: Mocked; let notificationServiceSpy: Mocked; + let workflowPersistServiceSpy: Mocked; + 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; fileSaverServiceSpy = TestBed.inject(FileSaverService) as unknown as Mocked; notificationServiceSpy = TestBed.inject(NotificationService) as unknown as Mocked; + workflowPersistServiceSpy = TestBed.inject(WorkflowPersistService) as unknown as Mocked; + 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 00000000000..cce6b27d4f4 --- /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; + + 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 bb8dfcce983..b477ff59792 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; +// 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 { + 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 `