diff --git a/frontend/src/app/common/service/computing-unit/computing-unit-actions/computing-unit-actions.service.spec.ts b/frontend/src/app/common/service/computing-unit/computing-unit-actions/computing-unit-actions.service.spec.ts index fe208457a4f..d31ce590950 100644 --- a/frontend/src/app/common/service/computing-unit/computing-unit-actions/computing-unit-actions.service.spec.ts +++ b/frontend/src/app/common/service/computing-unit/computing-unit-actions/computing-unit-actions.service.spec.ts @@ -149,6 +149,40 @@ describe("ComputingUnitActionsService", () => { expect(notificationService.error).toHaveBeenCalledWith("Failed to terminate computing unit"); }); + it("uses the disconnect wording and shows no destructive warning for a local unit", () => { + service.confirmAndTerminate(3, unit({ type: "local", name: "local1" })); + + const config = modalService.confirm.mock.calls[0][0]; + expect(config.nzTitle).toBe("Disconnect from Local Computing Unit"); + expect(config.nzOkText).toBe("Disconnect"); + expect(config.nzContent).toContain("disconnect from local1"); + // A local unit holds no execution results, so the data-loss warning must not appear. + // Assert the copy itself, not only its colour, so restyling the shared warning + // (a CSS class, a theme token) cannot silently void this guard. + expect(config.nzContent).not.toContain("Warning:"); + expect(config.nzContent).not.toContain("will be lost"); + expect(config.nzContent).not.toContain("#ff4d4f"); + // the cancel label is a fixed word; it must not co-vary with the unit type + expect(config.nzCancelText).toBe("Cancel"); + expect(config.nzOkType).toBe("primary"); + }); + + it("falls back to the kubernetes copy when the unit carries no type", () => { + // `type` is declared non-optional, so this pins the explicit `|| "kubernetes"` fallback. + // The name deliberately reuses the local case's "local1" so that `type` and `name` are not + // correlated across the suite: the copy cannot be keyed off the unit's name instead. + service.confirmAndTerminate(4, unit({ type: undefined, name: "local1" })); + + const config = modalService.confirm.mock.calls[0][0]; + expect(config.nzTitle).toBe("Terminate Computing Unit"); + expect(config.nzOkText).toBe("Terminate"); + expect(config.nzContent).toContain("terminate local1"); + expect(config.nzContent).toContain("Warning:"); + expect(config.nzContent).toContain("#ff4d4f"); + expect(config.nzCancelText).toBe("Cancel"); + expect(config.nzOkType).toBe("primary"); + }); + it("notifies an error with the extracted message when the termination observable errors", () => { statusService.terminateComputingUnit.mockReturnValue(throwError(() => new Error("kaboom"))); service.confirmAndTerminate(7, unit()); diff --git a/frontend/src/app/workspace/component/result-panel/result-panel.component.spec.ts b/frontend/src/app/workspace/component/result-panel/result-panel.component.spec.ts index c4236299b7b..339c772b521 100644 --- a/frontend/src/app/workspace/component/result-panel/result-panel.component.spec.ts +++ b/frontend/src/app/workspace/component/result-panel/result-panel.component.spec.ts @@ -18,9 +18,10 @@ */ import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { Component, ElementRef } from "@angular/core"; -import { CdkDragEnd } from "@angular/cdk/drag-drop"; +import { Component, ElementRef, Input } from "@angular/core"; +import { CdkDrag, CdkDragEnd } from "@angular/cdk/drag-drop"; import { NzResizeEvent } from "ng-zorro-antd/resizable"; +import { NzTooltipDirective } from "ng-zorro-antd/tooltip"; import { DEFAULT_HEIGHT, DEFAULT_WIDTH, ResultPanelComponent } from "./result-panel.component"; import { ExecuteWorkflowService } from "../../service/execute-workflow/execute-workflow.service"; @@ -173,8 +174,12 @@ describe("ResultPanelComponent", () => { component.dragPosition = { x: 5, y: 7 }; expect(component.isPanelDocked()).toBe(true); + // vary one axis at a time so each half of the conjunction is individually load-bearing component.dragPosition = { x: 5, y: 8 }; expect(component.isPanelDocked()).toBe(false); + + component.dragPosition = { x: 6, y: 7 }; + expect(component.isPanelDocked()).toBe(false); }); it("clearResultPanel empties the frame configs", () => { @@ -681,12 +686,20 @@ describe("ResultPanelComponent", () => { const resetSpy = vi.spyOn(component, "resetPanelPosition").mockImplementation(() => {}); expect(fixture.debugElement.query(By.css("#content"))).toBeTruthy(); + // Assert after each fire so the three controls are individually attributable: a + // single spy call cannot stand in for all of them, and swapping the two in-panel + // handlers changes which counter moves. fire("#result-buttons li[nz-menu-item]", "click", new MouseEvent("click")); // header close + expect(closeSpy).toHaveBeenCalledTimes(1); + expect(resetSpy).not.toHaveBeenCalled(); + fire("#panel-button button", "click", new MouseEvent("click")); // reset-position - fire("#panel-button li[nz-menu-item]", "click", new MouseEvent("click")); // in-panel close + expect(resetSpy).toHaveBeenCalledTimes(1); + expect(closeSpy).toHaveBeenCalledTimes(1); - expect(closeSpy).toHaveBeenCalled(); - expect(resetSpy).toHaveBeenCalled(); + fire("#panel-button li[nz-menu-item]", "click", new MouseEvent("click")); // in-panel close + expect(closeSpy).toHaveBeenCalledTimes(2); + expect(resetSpy).toHaveBeenCalledTimes(1); }); it("renders only the collapsed open button and wires openPanel when width is 0", () => { @@ -729,14 +742,29 @@ describe("ResultPanelComponent", () => { it("renders a tab per frame when frames are present", () => { component.width = DEFAULT_WIDTH; - component.frameComponentConfigs.set("Result", { component: StubFrame, componentInputs: {} }); - component.frameComponentConfigs.set("Console", { component: StubFrame, componentInputs: {} }); + // Seed real inputs: the outlet binds `inputs: config.value.componentInputs`, and that is + // the only thing that delivers operatorId into the real result / console / error frames. + component.frameComponentConfigs.set("Result", { + component: StubFrame, + componentInputs: { operatorId: "op-result" }, + }); + component.frameComponentConfigs.set("Console", { + component: StubFrame, + componentInputs: { operatorId: "op-console" }, + }); fixture.detectChanges(); const text = (fixture.nativeElement as HTMLElement).textContent ?? ""; expect(text).toContain("Result"); expect(text).toContain("Console"); expect(text).not.toContain("No results available to display."); + + const renderedInputs = fixture.debugElement + .queryAll(By.directive(StubFrame)) + .map(frame => (frame.componentInstance as StubFrame).operatorId); + expect(renderedInputs.length).toBeGreaterThan(0); + expect(renderedInputs).not.toContain(undefined); + renderedInputs.forEach(operatorId => expect(["op-result", "op-console"]).toContain(operatorId)); }); it("renders the resize handles when the panel is docked", () => { @@ -746,10 +774,131 @@ describe("ResultPanelComponent", () => { expect(fixture.debugElement.query(By.css("nz-resize-handles"))).toBeTruthy(); }); + + // The two tests below leave isPanelDocked unmocked on purpose: the real predicate has to run + // for the template's `isPanelDocked() ? ... : ...` direction list to be exercised both ways. + // The spy is installed without an implementation, so behaviour is untouched; asserting that + // it was called is what stops the call being replaced by an inline template expression. + it("offers only the right handle while the panel sits docked in its return position", () => { + component.width = DEFAULT_WIDTH; + component.returnPosition = { x: 0, y: 0 }; + component.dragPosition = { x: 0, y: 0 }; + const dockedSpy = vi.spyOn(component, "isPanelDocked"); + fixture.detectChanges(); + + const handles = fixture.debugElement.queryAll(By.css("nz-resize-handle")); + expect(dockedSpy).toHaveBeenCalled(); + expect(handles.length).toBe(1); + expect((handles[0].nativeElement as HTMLElement).className).toContain("nz-resizable-handle-right"); + }); + + it("offers all three resize directions once the panel has been dragged away from its return position", () => { + component.width = DEFAULT_WIDTH; + component.returnPosition = { x: 0, y: 0 }; + // Only the y axis moves, so a direction list keyed off the x axis alone would still + // report the panel as docked and render a single handle. + component.dragPosition = { x: 0, y: 60 }; + const dockedSpy = vi.spyOn(component, "isPanelDocked"); + fixture.detectChanges(); + + const classNames = fixture.debugElement + .queryAll(By.css("nz-resize-handle")) + .map(handle => (handle.nativeElement as HTMLElement).className); + expect(dockedSpy).toHaveBeenCalled(); + expect(classNames.length).toBe(3); + expect(classNames.some(c => c.includes("nz-resizable-handle-right"))).toBe(true); + expect(classNames.some(c => c.includes("nz-resizable-handle-bottom"))).toBe(true); + expect(classNames.some(c => c.includes("nz-resizable-handle-bottomRight"))).toBe(true); + }); + + it("names the selected operator in the collapsed open button's tooltip", () => { + component.width = 0; + component.operatorTitle = "My Operator"; + fixture.detectChanges(); + + const openButton = fixture.debugElement.query(By.css("#result-buttons li[nz-menu-item]")); + expect(openButton.injector.get(NzTooltipDirective).directiveTitle).toBe("Open Result Panel: My Operator"); + }); + + it("omits the separator from the collapsed open button's tooltip when no operator is selected", () => { + component.width = 0; + component.operatorTitle = ""; + fixture.detectChanges(); + + const openButton = fixture.debugElement.query(By.css("#result-buttons li[nz-menu-item]")); + expect(openButton.injector.get(NzTooltipDirective).directiveTitle).toBe("Open Result Panel"); + }); + + // The expanded panel swaps the same menu slot for a Close item carrying its own, + // near-identical tooltip; both arms of its ternary need their own assertions or the + // interpolation is only executed, never read. + it("names the selected operator in the expanded close button's tooltip", () => { + component.width = DEFAULT_WIDTH; + component.operatorTitle = "My Operator"; + fixture.detectChanges(); + + const closeButton = fixture.debugElement.query(By.css("#result-buttons li[nz-menu-item]")); + expect(closeButton.injector.get(NzTooltipDirective).directiveTitle).toBe("Close Result Panel: My Operator"); + }); + + it("omits the separator from the expanded close button's tooltip when no operator is selected", () => { + component.width = DEFAULT_WIDTH; + component.operatorTitle = ""; + fixture.detectChanges(); + + const closeButton = fixture.debugElement.query(By.css("#result-buttons li[nz-menu-item]")); + expect(closeButton.injector.get(NzTooltipDirective).directiveTitle).toBe("Close Result Panel"); + }); + + it("routes the container's drag and resize outputs to the component handlers", () => { + // onResize defers its assignment through requestAnimationFrame; run the callback + // synchronously so the width/height assertions are deterministic. + const cancelSpy = vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => {}); + const rafSpy = vi.spyOn(window, "requestAnimationFrame").mockImplementation(cb => { + cb(0); + return 1; + }); + // handleStartDrag/handleEndDrag dereference the #dynamicComponent ViewChild, which only + // resolves once the panel is open and has at least one frame to render. + component.width = DEFAULT_WIDTH; + component.returnPosition = { x: 0, y: 0 }; + component.frameComponentConfigs.set("Result", { component: StubFrame, componentInputs: {} }); + fixture.detectChanges(); + const startDragSpy = vi.spyOn(component, "handleStartDrag"); + + try { + fire("#result-container", "cdkDragStarted", {}); + fire("#result-container", "cdkDragEnded", { + source: { getFreeDragPosition: () => ({ x: 11, y: 22 }) }, + }); + fire("#result-container", "nzResize", { width: 700, height: 400 }); + + expect(startDragSpy).toHaveBeenCalledTimes(1); + expect(component.dragPosition).toEqual({ x: 11, y: 22 }); + expect(component.width).toBe(700); + expect(component.height).toBe(400); + + // Recording the fields is only half the wiring: push a change-detection pass and + // assert the recorded values actually reach the DOM, i.e. that the container is + // sized from width/height and keeps the dropped position rather than snapping back. + fixture.detectChanges(); + const container = fixture.debugElement.query(By.css("#result-container")); + expect(container.injector.get(CdkDrag).freeDragPosition).toEqual({ x: 11, y: 22 }); + const host = container.nativeElement as HTMLElement; + expect(host.style.width).toBe("700px"); + expect(host.style.height).toBe("400px"); + } finally { + rafSpy.mockRestore(); + cancelSpy.mockRestore(); + } + }); }); }); // A trivial standalone frame so *ngComponentOutlet can instantiate a tab's content -// without pulling in a real result-frame component's dependency graph. +// without pulling in a real result-frame component's dependency graph. It declares the +// one input every real frame takes, so the outlet's `inputs:` half is observable. @Component({ standalone: true, template: "" }) -class StubFrame {} +class StubFrame { + @Input() operatorId?: string; +}