Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ import { ListItemComponent } from "./list-item.component";
import { WorkflowPersistService } from "src/app/common/service/workflow-persist/workflow-persist.service";
import { HttpClientTestingModule } from "@angular/common/http/testing";
import { NzModalService } from "ng-zorro-antd/modal";
import { of, throwError } from "rxjs";
import { of, Subject, throwError } from "rxjs";
import { ActionType, HubService } from "../../../../hub/service/hub.service";
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
import { RouterTestingModule } from "@angular/router/testing";
import { StubUserService } from "../../../../common/service/user/stub-user.service";
Expand All @@ -45,6 +46,8 @@ describe("ListItemComponent", () => {
let fixture: ComponentFixture<ListItemComponent>;
let workflowPersistService: Mocked<WorkflowPersistService>;
let datasetService: Mocked<DatasetService>;
let hubService: HubService;
let modalService: NzModalService;

beforeEach(async () => {
const workflowPersistServiceSpy = { updateWorkflowName: vi.fn(), updateWorkflowDescription: vi.fn() };
Expand All @@ -65,6 +68,8 @@ describe("ListItemComponent", () => {
component = fixture.componentInstance;
workflowPersistService = TestBed.inject(WorkflowPersistService) as unknown as Mocked<WorkflowPersistService>;
datasetService = TestBed.inject(DatasetService) as unknown as Mocked<DatasetService>;
hubService = TestBed.inject(HubService);
modalService = TestBed.inject(NzModalService);
// initializeEntry() needs a fully-formed workflow entry to avoid throwing
// when the template renders for the first time. Each test below overwrites
// component.entry directly, which exercises confirm methods without going
Expand Down Expand Up @@ -247,4 +252,102 @@ describe("ListItemComponent", () => {
expect(component.entry.name).toBe("old-name");
expect(component.editingName).toBe(false);
});

describe("edit + interaction handlers", () => {
it("onEditName captures the original name and enters edit mode", () => {
component.entry = { id: 1, name: "My Name", type: "workflow" } as unknown as DashboardEntry;

component.onEditName();

expect(component.editingName).toBe(true);
expect(component.originalName).toBe("My Name");
});

it("onEditDescription opens the edit modal and applies the change, then closes it", () => {
component.editable = true;
component.entry = { id: 1, description: "old", type: "workflow" } as unknown as DashboardEntry;
const descriptionChange = new Subject<string>();
const modalRef = { componentInstance: { descriptionChange }, destroy: vi.fn() };
const createSpy = vi.spyOn(modalService, "create").mockReturnValue(modalRef as any);
const confirmSpy = vi.spyOn(component, "confirmUpdateCustomDescription").mockImplementation(() => {});

component.onEditDescription();
expect(createSpy).toHaveBeenCalled();

descriptionChange.next("new description");
expect(confirmSpy).toHaveBeenCalledWith("new description");
expect(modalRef.destroy).toHaveBeenCalled();
});

it("onEditDescription is a no-op when the entry is not editable", () => {
component.editable = false;
const createSpy = vi.spyOn(modalService, "create");

component.onEditDescription();

expect(createSpy).not.toHaveBeenCalled();
});

it("onCheckboxChange toggles the entry's checked flag and emits the change", () => {
const entry = { checked: false } as unknown as DashboardEntry;
const emitSpy = vi.fn();
component.checkboxChanged.subscribe(emitSpy);

component.onCheckboxChange(entry);

expect(entry.checked).toBe(true);
expect(emitSpy).toHaveBeenCalled();
});

it("toggleLike likes the entry and refreshes the like count on success", () => {
component.currentUid = 1;
component.entry = { id: 5, type: "workflow" } as unknown as DashboardEntry;
component.isLiked = false;
vi.spyOn(hubService, "postLike").mockReturnValue(of(true));
vi.spyOn(hubService, "getCounts").mockReturnValue(of([{ counts: { like: 3 } }] as any));

component.toggleLike();

expect(hubService.postLike).toHaveBeenCalledWith(5, "workflow");
expect(component.isLiked).toBe(true);
expect(component.likeCount).toBe(3);
});

it("toggleLike unlikes the entry and refreshes the like count on success", () => {
component.currentUid = 1;
component.entry = { id: 5, type: "workflow" } as unknown as DashboardEntry;
component.isLiked = true;
vi.spyOn(hubService, "postUnlike").mockReturnValue(of(true));
vi.spyOn(hubService, "getCounts").mockReturnValue(of([{ counts: { like: 1 } }] as any));

component.toggleLike();

expect(hubService.postUnlike).toHaveBeenCalledWith(5, "workflow");
expect(component.isLiked).toBe(false);
expect(component.likeCount).toBe(1);
});

it("toggleLike does nothing when there is no current user", () => {
component.currentUid = undefined;
component.entry = { id: 5, type: "workflow" } as unknown as DashboardEntry;
const postLikeSpy = vi.spyOn(hubService, "postLike");

component.toggleLike();

expect(postLikeSpy).not.toHaveBeenCalled();
});

it("openDetailModal opens the detail modal and increments the view count", () => {
component.entry = { id: 9, type: "workflow" } as unknown as DashboardEntry;
const modalRef = { componentInstance: {}, destroy: vi.fn() };
vi.spyOn(modalService, "create").mockReturnValue(modalRef as any);
vi.spyOn(hubService, "getCounts").mockReturnValue(of([{ counts: { view: 4 } }] as any));

component.openDetailModal(9);

expect(modalService.create).toHaveBeenCalled();
expect(hubService.getCounts).toHaveBeenCalledWith(["workflow"], [9], [ActionType.View]);
expect(component.viewCount).toBe(5); // 4 + 1
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,21 @@

import { TestBed } from "@angular/core/testing";
import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing";
import { DownloadService } from "./download.service";
import { DownloadService, EXPORT_BASE_URL } 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 { firstValueFrom, lastValueFrom, of, throwError } from "rxjs";
import { commonTestProviders } from "../../../../common/testing/test-utils";
import type { Mocked } from "vitest";
import { WORKFLOW_EXECUTIONS_API_BASE_URL } from "../workflow-executions/workflow-executions.service";
import { DashboardWorkflowComputingUnit } from "../../../../common/type/workflow-computing-unit";

function computingUnit(type: string, cuid: number): DashboardWorkflowComputingUnit {
return { computingUnit: { cuid, type } } as unknown as DashboardWorkflowComputingUnit;
}
const EXPORT_OPERATORS = [{ id: "op1", outputType: "csv" }];

describe("DownloadService", () => {
let downloadService: DownloadService;
Expand Down Expand Up @@ -278,4 +285,144 @@ describe("DownloadService", () => {
const map = await promise;
expect(map).toEqual({ "op-1": ["my-dataset"], "op-2": [] });
});

// ─── exportWorkflowResultToDataset ────────────────────────────────────────

it("POSTs the dataset export request and returns the response body", async () => {
const promise = lastValueFrom(
downloadService.exportWorkflowResultToDataset(
"csv",
1,
"WF",
EXPORT_OPERATORS,
[7],
0,
0,
"out.csv",
computingUnit("local", 5)
)
);

const req = httpMock.expectOne(`${WORKFLOW_EXECUTIONS_API_BASE_URL}/${EXPORT_BASE_URL}/dataset`);
expect(req.request.method).toBe("POST");
expect(req.request.body).toMatchObject({
exportType: "csv",
workflowId: 1,
datasetIds: [7],
filename: "out.csv",
computingUnitId: 5,
});
expect(req.request.headers.get("Accept")).toBe("application/json");
req.flush({ status: "ok", message: "done" });

const res = await promise;
expect(res.body).toEqual({ status: "ok", message: "done" });
});

it("appends the cuid query param for a kubernetes computing unit", () => {
downloadService
.exportWorkflowResultToDataset(
"csv",
1,
"WF",
EXPORT_OPERATORS,
[7],
0,
0,
"out.csv",
computingUnit("kubernetes", 9)
)
.subscribe();

const req = httpMock.expectOne(`${WORKFLOW_EXECUTIONS_API_BASE_URL}/${EXPORT_BASE_URL}/dataset?cuid=9`);
expect(req.request.method).toBe("POST");
req.flush({ status: "ok", message: "done" });
});

// ─── exportWorkflowResultToLocal ──────────────────────────────────────────

describe("exportWorkflowResultToLocal", () => {
let submitSpy: ReturnType<typeof vi.spyOn>;
let setTimeoutSpy: ReturnType<typeof vi.spyOn>;
let cleanup: (() => void) | undefined;

beforeEach(() => {
// Stub form.submit (jsdom does not implement it) so we can assert it fired, and
// capture the 10s cleanup callback instead of scheduling a real timer that could
// fire during a later test (a leaked timer is flaky). Fake timers are avoided
// because they make localStorage unavailable in this environment.
submitSpy = vi.spyOn(HTMLFormElement.prototype, "submit").mockImplementation(() => {});
cleanup = undefined;
setTimeoutSpy = vi.spyOn(window, "setTimeout").mockImplementation((handler: TimerHandler) => {
cleanup = handler as () => void;
return 0 as unknown as ReturnType<typeof setTimeout>;
});
// localStorage is not available in this jsdom service-test environment; stub it so
// the method can read the auth token deterministically.
vi.stubGlobal("localStorage", {
getItem: vi.fn().mockReturnValue("tok-123"),
setItem: vi.fn(),
removeItem: vi.fn(),
});
});

afterEach(() => {
submitSpy.mockRestore();
setTimeoutSpy.mockRestore();
vi.unstubAllGlobals();
document
.querySelectorAll('form[target="download-iframe"], iframe[name="download-iframe"]')
.forEach(el => el.remove());
});

it("builds and submits a hidden form carrying the request and token, then cleans up on timeout", () => {
downloadService.exportWorkflowResultToLocal(
"csv",
1,
"WF",
EXPORT_OPERATORS,
0,
0,
"out.csv",
computingUnit("local", 5)
);

const form = document.querySelector('form[target="download-iframe"]') as HTMLFormElement;
expect(form).toBeTruthy();
expect(form.getAttribute("action")).toBe(`${WORKFLOW_EXECUTIONS_API_BASE_URL}/${EXPORT_BASE_URL}/local`);
expect(form.method).toBe("post");
expect(submitSpy).toHaveBeenCalledTimes(1);

const requestInput = form.querySelector('input[name="request"]') as HTMLInputElement;
expect(JSON.parse(requestInput.value)).toMatchObject({
exportType: "csv",
workflowId: 1,
computingUnitId: 5,
datasetIds: [],
});
expect((form.querySelector('input[name="token"]') as HTMLInputElement).value).toBe("tok-123");

// Running the captured cleanup callback removes the form and the iframe.
expect(document.querySelector('iframe[name="download-iframe"]')).toBeTruthy();
cleanup?.();
expect(document.querySelector('form[target="download-iframe"]')).toBeNull();
expect(document.querySelector('iframe[name="download-iframe"]')).toBeNull();
});

it("targets the cuid-scoped endpoint for a kubernetes computing unit", () => {
downloadService.exportWorkflowResultToLocal(
"csv",
1,
"WF",
EXPORT_OPERATORS,
0,
0,
"out.csv",
computingUnit("kubernetes", 9)
);

const form = document.querySelector('form[target="download-iframe"]') as HTMLFormElement;
expect(form.getAttribute("action")).toBe(`${WORKFLOW_EXECUTIONS_API_BASE_URL}/${EXPORT_BASE_URL}/local?cuid=9`);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ import { UndoRedoService } from "../undo-redo/undo-redo.service";
import { mockPoint, mockScanPredicate } from "../workflow-graph/model/mock-workflow-data";
import { serializePortIdentity } from "../../../common/util/port-identity-serde";
import { commonTestImports, commonTestProviders } from "../../../common/testing/test-utils";
import { firstValueFrom } from "rxjs";
import { CompilationState } from "../../types/workflow-compiling.interface";

describe("WorkflowCompilingService.dropInvalidAttributeValues", () => {
// A schema shaped like the Aggregate operator after schema propagation has filled in the
Expand Down Expand Up @@ -240,3 +242,89 @@ describe("WorkflowCompilingService schema propagation property cleanup", () => {
expect(workflowActionService.getTexeraGraph().getOperator(operatorID).operatorProperties.attribute).toBe("col_y");
});
});

describe("WorkflowCompilingService public getters", () => {
let service: WorkflowCompilingService;

beforeEach(() => {
TestBed.configureTestingModule({
imports: [...commonTestImports],
providers: [
{ provide: OperatorMetadataService, useClass: StubOperatorMetadataService },
JointUIService,
WorkflowActionService,
WorkflowUtilService,
UndoRedoService,
DynamicSchemaService,
ValidationWorkflowService,
WorkflowCompilingService,
...commonTestProviders,
],
});
service = TestBed.inject(WorkflowCompilingService);
});

// Overwrite the private compilation-state snapshot the getters read from.
const setState = (info: unknown): void => {
(service as any).currentCompilationStateInfo = info;
};

it("getWorkflowCompilationState returns the current state", () => {
setState({ state: CompilationState.Succeeded });
expect(service.getWorkflowCompilationState()).toBe(CompilationState.Succeeded);
});

it("getWorkflowCompilationErrors is empty while succeeded or uninitialized", () => {
setState({ state: CompilationState.Succeeded, operatorErrors: { op1: { message: "x" } } });
expect(service.getWorkflowCompilationErrors()).toEqual({});

setState({ state: CompilationState.Uninitialized });
expect(service.getWorkflowCompilationErrors()).toEqual({});
});

it("getWorkflowCompilationErrors surfaces the operator errors when compilation failed", () => {
const errors = { op1: { message: "boom" } };
setState({ state: CompilationState.Failed, operatorOutputPortSchemaMap: {}, operatorErrors: errors });
expect(service.getWorkflowCompilationErrors()).toBe(errors);
});

it("getCompilationStateInfoChangedStream replays the latest state", async () => {
(service as any).compilationStateInfoChangedStream.next(CompilationState.Succeeded);
expect(await firstValueFrom(service.getCompilationStateInfoChangedStream())).toBe(CompilationState.Succeeded);
});

it("getOperatorOutputSchemaMap returns undefined when uninitialized", () => {
setState({ state: CompilationState.Uninitialized });
expect(service.getOperatorOutputSchemaMap("op1")).toBeUndefined();
});

it("getOperatorOutputSchemaMap returns the operator's output port schema map", () => {
const opMap = {
[serializePortIdentity({ id: 0, internal: false })]: [{ attributeName: "a", attributeType: "string" }],
};
setState({ state: CompilationState.Succeeded, operatorOutputPortSchemaMap: { op1: opMap } });
expect(service.getOperatorOutputSchemaMap("op1")).toBe(opMap);
});

it("getPortInputSchema looks the port up by its serialized identity", () => {
const portSchema = [{ attributeName: "a", attributeType: "string" }];
vi.spyOn(service, "getOperatorInputSchemaMap").mockReturnValue({
[serializePortIdentity({ id: 0, internal: false })]: portSchema,
} as any);
expect(service.getPortInputSchema("op1", 0)).toBe(portSchema);
});

it("getPortInputSchema returns undefined when the operator has no input schema map", () => {
vi.spyOn(service, "getOperatorInputSchemaMap").mockReturnValue(undefined);
expect(service.getPortInputSchema("op1", 0)).toBeUndefined();
});

it("getOperatorInputAttributeType finds the named attribute's type on the input port", () => {
vi.spyOn(service, "getPortInputSchema").mockReturnValue([
{ attributeName: "a", attributeType: "string" },
{ attributeName: "b", attributeType: "integer" },
]);
expect(service.getOperatorInputAttributeType("op1", 0, "b")).toBe("integer");
expect(service.getOperatorInputAttributeType("op1", 0, "missing")).toBeUndefined();
});
});
Loading